Sync upstream UI fixes through c0630f3d #7

Merged
sindoring merged 29 commits from sync/upstream-20261011-latest into main 2026-10-11 17:52:10 +03:00
43 changed files with 861 additions and 228 deletions
Generated
+10
View File
@@ -778,6 +778,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e"
dependencies = [
"bevy_dylib",
"bevy_internal",
"tracing",
]
@@ -1097,6 +1098,15 @@ dependencies = [
"sysinfo",
]
[[package]]
name = "bevy_dylib"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f"
dependencies = [
"bevy_internal",
]
[[package]]
name = "bevy_ecs"
version = "0.18.1"
+3
View File
@@ -16,6 +16,9 @@ dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:be
tracy = ["bevy/trace_tracy"]
# Writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
# Links Bevy as a shared library so an incremental rebuild relinks faster. The binary then starts
# only through `cargo run`, which puts the toolchain's libstd on the loader path.
bevy_dynamic_linking = ["bevy/dynamic_linking"]
[dependencies]
anyhow.workspace = true
+118 -31
View File
@@ -158,11 +158,11 @@ impl Addon {
// order, as `AddOn_Load 0x51f240` hands every listed file to `0x6edb90`.
match script.run_chunk_named(&bytes, &self.chunk_name(file, &path)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
Err(err) => {
let e = format!("{}/{file}: {err}", self.name);
error!("ui_script: {e}");
// A script error: it reaches the player through the Lua error handler.
script.report_script_error(&e);
script.report_script_error(&e, &benilla_ui::script::lua_message(&err));
failures.push(e);
}
}
@@ -210,11 +210,11 @@ impl Addon {
script.report_load_failure(&e);
failures.push(e);
}
for e in &report.errors {
for (e, heard) in report.raised() {
error!("ui_script({}/{file}): {e}", self.name);
// A script error, as in the Lua arm; `_ERRORMESSAGE`'s IsVisible guard shows only
// a burst's first, as in the reference.
script.report_script_error(&format!("{}/{file}: {e}", self.name));
script.report_script_error(&format!("{}/{file}: {e}", self.name), heard);
failures.push(format!("{}/{file}: {e}", self.name));
}
info!(
@@ -237,39 +237,18 @@ fn is_lua(entry: &str) -> bool {
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
}
/// `root/rel`, refusing to escape `root`. `rel` is already resolved by
/// [`benilla_ui::loader::join_ref`], so an escape shows as a leading `..`, refused with anything
/// absolute before any filesystem call; a symlinked addon folder still works.
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
let rel = Path::new(rel);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
std::fs::read(root.join(rel)).ok()
}
/// The one file namespace, install-relative, as in the reference: `0x647e60` answers a name from
/// the install tree's hash index, then the archive chain, after `0x6ede10` collapses `..`. So an
/// addon's `..\Blizzard_AuctionUI\…` reaches the archive behind a `.pub`-only folder. Only a path
/// under `Interface/AddOns/` touches the filesystem, and never above the AddOns root.
/// under `Interface/AddOns/` touches the filesystem, never above the AddOns root, and each of its
/// components matches case-insensitively, as on the reference's Windows filesystem: the resolver
/// addon art and sound use ([`benilla_assets::loose_addon_file`]).
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
under_addons(req)
.and_then(|rel| read_under(root, rel))
benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(req))
.and_then(|file| std::fs::read(file).ok())
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
}
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` without it; matched
/// case-insensitively, as the reference is a Windows client (`Interface\Addons\` is the same path).
fn under_addons(req: &str) -> Option<&str> {
let rest = req.get(ADDONS_PREFIX.len()..)?;
req.get(..ADDONS_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
.map(|_| rest)
}
/// A `Dir` addon's base before its folder name ([`Addon::prefix`]), `/`-separated as `join_ref` is.
const ADDONS_PREFIX: &str = "Interface/AddOns/";
@@ -1244,6 +1223,44 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// A manifest entry names its file in whatever case its author typed on Windows: Questie's
/// `.toc` lists `Astrolabe\Astrolabe.lua` from a folder shipped as `astrolabe`. Every
/// component matches case-insensitively, the folder and the file alike, on any filesystem.
#[test]
fn an_addon_file_is_found_whatever_the_case_of_its_path() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("!Probe/astrolabe")).unwrap();
std::fs::write(
root.join("!Probe/astrolabe/astrolabe.lua"),
"Astrolabe = {}",
)
.unwrap();
let addon = Addon {
name: "!Probe".into(),
toc: Toc::default(),
source: Source::Dir(root),
};
for req in [
"Interface/AddOns/!Probe/Astrolabe/Astrolabe.lua",
"Interface/AddOns/!PROBE/ASTROLABE/ASTROLABE.LUA",
"interface/addons/!probe/astrolabe/astrolabe.lua",
] {
assert_eq!(
addon.read(req).as_deref(),
Some(&b"Astrolabe = {}"[..]),
"{req} names the file on disk as the reference's filesystem would"
);
}
assert!(addon
.read("Interface/AddOns/!Probe/astrolabe/missing.lua")
.is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
/// A `.toc`-listed `Bindings.xml` is walked as FrameXML, each `<Binding>` an unknown frame
/// type, which the reference logs and carries on from (`0x6ee356`).
#[test]
@@ -2874,4 +2891,74 @@ mod tests {
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The error handler hears what 1.12's `lua_pcall` error function hands it (`0x703b40`, from
/// `FS_DoBuffer 0x704ae0` for a file's top level and its syntax error, `0x704f10` for a
/// handler): the bare Lua message, with no frame, `OnLoad: `, addon or file word and no
/// traceback. The decorated text stays on the failures list and the host log.
#[test]
fn the_error_handler_hears_the_bare_message_of_every_load_failure() {
let tmp = std::env::temp_dir().join(format!("benilla-addon-bare-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let addon = dir_addon(
&tmp,
"Bare",
"## Interface: 11200\ntop.lua\nsyntax.lua\nframe.xml\n",
);
std::fs::write(tmp.join("Bare/top.lua"), "local t = nil\nt.x = 1\n").unwrap();
std::fs::write(tmp.join("Bare/syntax.lua"), "x = = 1\n").unwrap();
std::fs::write(
tmp.join("Bare/frame.xml"),
r#"<Ui><Frame name="BareFrame"><Scripts><OnLoad>this:NoSuchMethod()</OnLoad></Scripts></Frame>
<Script>local u = nil; u.y = 2</Script></Ui>"#,
)
.unwrap();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script
.run("Heard = {} seterrorhandler(function(m) table.insert(Heard, m) end)")
.unwrap();
let failures = addon.load(&script, &mut Status::default());
assert_eq!(failures.len(), 4, "{failures:#?}");
script.dispatch_script_errors_to_handler();
let heard: Vec<String> = script.eval("return Heard").unwrap();
assert_eq!(heard.len(), 4, "{heard:#?}");
for m in &heard {
assert!(
!m.contains("runtime error")
&& !m.contains("syntax error")
&& !m.contains("stack traceback")
&& !m.contains("OnLoad: ")
&& !m.contains("Bare/")
&& !m.contains('\n'),
"{m:?}"
);
}
assert!(
heard[0].ends_with("top.lua:2: attempt to index local `t' (a nil value)"),
"{:?}",
heard[0]
);
assert!(
heard[0].starts_with("Interface\\AddOns\\Bare\\top.lua"),
"{:?}",
heard[0]
);
assert!(
heard[1].contains("syntax.lua:1:") && heard[1].contains("unexpected symbol"),
"{:?}",
heard[1]
);
assert!(
heard[2].ends_with("attempt to call method `NoSuchMethod' (a nil value)"),
"{:?}",
heard[2]
);
assert!(
heard[3].ends_with("attempt to index local `u' (a nil value)"),
"{:?}",
heard[3]
);
let _ = std::fs::remove_dir_all(&tmp);
}
}
@@ -958,6 +958,57 @@ fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() {
}
}
/// A target whose faction template the server changes live recolours its plate: template 35
/// (friendly) to 14 (hostile) names no side either way, so only the template's own edge fires
/// `UNIT_FACTION`. No edge, or another unit's, leaves the plate as it was.
#[test]
fn a_live_faction_template_change_recolours_the_target_plate() {
benilla_formats::wow_data_or_skip!();
use crate::net::FieldEdges;
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
const MOB: u64 = 0xF130_0000_3796_0001;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let mob = |reaction: u8| UnitState {
exists: true,
has_object: true,
guid: MOB,
name: Some("Pusillin".into()),
health: 100,
max_health: 100,
level: 58,
reaction,
can_attack: reaction < 4,
..UnitState::default()
};
let is_green = |c: [f32; 4]| c[0].abs() < 1e-6 && (c[1] - 1.0).abs() < 1e-6;
let is_red = |c: [f32; 4]| (c[0] - 1.0).abs() < 1e-6 && c[1].abs() < 1e-6;
let friendly = mob(5);
s.set_unit("target", Some(friendly.clone()));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let before = plate_color(&mut s);
assert!(is_green(before), "template 35 reads green, got {before:?}");
let hostile = mob(2);
s.set_unit("target", Some(hostile.clone()));
let fire = |s: &mut UiScript, prev: &UnitState, edges: &[(u64, u16)]| {
crate::ui_unit::fire_transitions(s, "target", Some(prev), &hostile, &FieldEdges::of(edges));
};
fire(&mut s, &friendly, &[]);
let stale = plate_color(&mut s);
assert!(is_green(stale), "no edge, no repaint, got {stale:?}");
fire(&mut s, &hostile, &[(MOB + 1, FIELD_UNIT_FACTIONTEMPLATE)]);
let other = plate_color(&mut s);
assert!(is_green(other), "another unit's edge, got {other:?}");
fire(&mut s, &hostile, &[(MOB, FIELD_UNIT_FACTIONTEMPLATE)]);
let after = plate_color(&mut s);
assert!(is_red(after), "template 14 reads red, got {after:?}");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn
/// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border),
/// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it.
+13 -5
View File
@@ -1202,11 +1202,19 @@ pub(crate) fn fire_transitions(
}
// `UNIT_FACTION`, the PvP-icon repaint, on the fields the icon reads and on the tapped bit: the
// reference's dynamic-flags watcher (`0x600440`) fires event 29 on bit `0x4` (`0x6005a1` →
// `0x6005b0`) and has no arm for `0x8`.
if prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
}) {
// `0x6005b0`) and has no arm for `0x8`. Also on any change of `UNIT_FIELD_FACTIONTEMPLATE`, the
// watch bridge's id 29 (`0x51bbb0`, as for `UNIT_FLAGS` above): a template swap between two
// that name no faction group moves nothing in the tuple, yet changes the reaction.
let template_moved = edges.moved(
cur.guid,
benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE,
);
if template_moved
|| prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
})
{
script.fire_event("UNIT_FACTION", vec![tok()]);
}
}
@@ -187,13 +187,17 @@ pub fn read_chain_or_loose(
}
/// Map a normalized `interface\addons\…` path onto a file under the addon root, matching each
/// component case-insensitively as the Windows reference does; a dot-component is refused, so a
/// path cannot escape the root.
/// component case-insensitively as the Windows reference does; a dot-component, or one the host
/// reads as more than a plain name (a Windows `C:`), is refused, so a path cannot escape the root.
pub fn loose_addon_file(root: &Path, candidate: &str) -> Option<PathBuf> {
let rel = candidate.strip_prefix("interface\\addons\\")?;
let mut at = root.to_path_buf();
for comp in rel.split('\\') {
if comp.is_empty() || comp.starts_with('.') {
let plain = matches!(
Path::new(comp).components().collect::<Vec<_>>()[..],
[std::path::Component::Normal(_)]
);
if !plain || comp.starts_with('.') {
return None;
}
let direct = at.join(comp);
+1 -1
View File
@@ -15,7 +15,7 @@ impl Loader<'_> {
let table = match self.build_backdrop_table(bd) {
Ok(t) => t,
Err(e) => {
self.report.errors.push(format!("{dbg}: <Backdrop>: {e}"));
self.report.fault(format!("{dbg}: <Backdrop>: {e}"));
return;
}
};
+1 -3
View File
@@ -146,9 +146,7 @@ impl Loader<'_> {
let region: Table = match wrapper.call_method("CreateTitleRegion", ()) {
Ok(r) => r,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateTitleRegion: {e}"));
self.report.fault(format!("{dbg}: CreateTitleRegion: {e}"));
return;
}
};
+48 -23
View File
@@ -22,6 +22,33 @@ mod regions;
mod scripts;
mod widgets;
impl LoadReport {
/// A load fault that is no script raise: the handler hears the row's own text.
pub(super) fn fault(&mut self, text: String) {
self.handler_errors.push(text.clone());
self.errors.push(text);
}
/// A script raise: the row is `text`, the handler hears the bare Lua message of `e`.
pub(super) fn raise(&mut self, text: String, e: &mlua::Error) {
self.handler_errors.push(crate::script::lua_message(e));
self.errors.push(text);
}
/// Each of [`Self::errors`] with what the Lua error handler is called with for it.
pub fn raised(&self) -> impl Iterator<Item = (&str, &str)> {
debug_assert_eq!(
self.errors.len(),
self.handler_errors.len(),
"every error row is written through `fault` or `raise`"
);
self.errors
.iter()
.map(String::as_str)
.zip(self.handler_errors.iter().map(String::as_str))
}
}
/// The outcome of a [`load`]; nothing aborts a load, as the reference logs and continues.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LoadReport {
@@ -31,6 +58,11 @@ pub struct LoadReport {
/// Failures the reference raises on: a handler that fails to compile, a method call that
/// errors, a malformed included document.
pub errors: Vec<String>,
/// What the Lua error handler is called with for each of [`Self::errors`], row for row
/// ([`Self::raised`]): the bare Lua message of a raise, as `lua_pcall` leaves it for the
/// reference's error function (`0x703b40`), and the row's own text for a fault that is no
/// script raise.
handler_errors: Vec<String>,
/// `<Include file=>` and `<Script file=>` paths the provider lacks. The reference logs
/// `"Couldn't open %s"` (`0x6edaa0`, `0x846ff4`) or `"Error loading %s"` (`0x872e50`) and goes
/// on (`0x6ee00d`); kept apart from `warnings` so a load that resolved nothing is not clean.
@@ -101,15 +133,17 @@ pub fn load_into_under(
loader.report
}
/// Apply a template to an existing frame, `CreateFrame`'s fourth argument. Returns the warnings,
/// then the errors. The frame always survives: an unknown name leaves it bare, as in the
/// reference, and only virtual elements are registered. The frame's own name is the `$parent`
/// Apply a template to an existing frame, `CreateFrame`'s fourth argument. Returns the report:
/// its errors are script errors for the error handler, as the reference's `CreateFrame` builds the
/// template through the XML instantiator `0x6ee280`, whose OnLoad runs under the error function
/// (`0x704d50`). The frame always survives an error, and only virtual elements are registered; an
/// unknown name never gets here, since the binding raises first. The frame's own name is the `$parent`
/// base (`MyButton` makes `$parentText` `MyButtonText`). Runs inside the `CreateFrame` binding, so
/// it enters at [`Loader::decorate`]: [`Loader::materialize`] would recurse.
pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &str) -> Vec<String> {
pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &str) -> LoadReport {
let template = template.trim();
if template.is_empty() {
return Vec::new();
return LoadReport::default();
}
// A template never reads a file: `<Include>` and `<Script file=>` are top-level only.
let no_files = |_: &str| -> Option<Vec<u8>> { None };
@@ -179,9 +213,7 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s
loader.decorate(&expanded, wrapper, &self_name, &parent_name, &dbg);
let mut out = loader.report.warnings;
out.extend(loader.report.errors);
out
loader.report
}
/// Join a FrameXML reference to its file's directory, lexically: `\` and `/` both separate, `..`
@@ -352,8 +384,7 @@ impl Loader<'_> {
Some(bytes) => {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Script file=\"{path}\">: {e}"));
.raise(format!("<Script file=\"{path}\">: {e}"), &e);
}
}
None => {
@@ -377,7 +408,7 @@ impl Loader<'_> {
// `[string "Interface\FrameXML\Foo.xml:<Scripts>"]`, not a file path.
let name = format!("{}:<Scripts>", self.path.replace('/', "\\"));
if let Err(e) = self.run_named(&chunk, &name) {
self.report.errors.push(format!("inline <Script>: {e}"));
self.report.raise(format!("inline <Script>: {e}"), &e);
}
}
TopLevel::Font(el) => self.do_font(el),
@@ -418,8 +449,7 @@ impl Loader<'_> {
if status::runs_as_lua(&joined) {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Include file=\"{path}\">: {e}"));
.raise(format!("<Include file=\"{path}\">: {e}"), &e);
}
return;
}
@@ -441,9 +471,7 @@ impl Loader<'_> {
Err(e) => {
let line = status::unparsed(&self.status_path(&joined));
self.report.status.report(status::FAILURE, line);
self.report
.errors
.push(format!("<Include file=\"{path}\">: {e}"))
self.report.fault(format!("<Include file=\"{path}\">: {e}"))
}
}
}
@@ -600,8 +628,7 @@ impl Loader<'_> {
Ok(f) => f,
Err(e) => {
self.report
.errors
.push(format!("CreateFrame global missing: {e}"));
.fault(format!("CreateFrame global missing: {e}"));
return None;
}
};
@@ -609,7 +636,7 @@ impl Loader<'_> {
match create.call((el.tag.clone(), resolved_name.clone(), parent.cloned())) {
Ok(w) => w,
Err(e) => {
self.report.errors.push(format!(
self.report.fault(format!(
"CreateFrame(<{}>{}): {e}",
el.tag,
resolved_name
@@ -699,7 +726,7 @@ impl Loader<'_> {
// and gives the ScrollFrame its scroll range. An empty one errors in the reference.
for sc in children_named(el, "ScrollChild") {
let Some(child) = sc.children.first() else {
self.report.errors.push(format!(
self.report.fault(format!(
"{dbg_name}: <ScrollChild> is empty — the reference errors here, and a \
ScrollFrame with no child has no scroll range at all"
));
@@ -710,9 +737,7 @@ impl Loader<'_> {
continue; // materialize already reported why
};
if let Err(e) = wrapper.call_method::<()>("SetScrollChild", child_wrapper) {
self.report
.errors
.push(format!("{dbg_name}: <ScrollChild>: {e}"));
self.report.fault(format!("{dbg_name}: <ScrollChild>: {e}"));
}
}
+7 -15
View File
@@ -53,13 +53,13 @@ impl Loader<'_> {
match wrapper.call_method(method, (rname, Some(level.clone()))) {
Ok(w) => w,
Err(e) => {
self.report.errors.push(format!("{dbg}: {method}: {e}"));
self.report.fault(format!("{dbg}: {method}: {e}"));
continue;
}
};
if region.attr_bool("hidden") {
if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) {
self.report.errors.push(format!("{dbg}: region Hide: {e}"));
self.report.fault(format!("{dbg}: region Hide: {e}"));
}
}
if let Some(a) = region
@@ -85,9 +85,7 @@ impl Loader<'_> {
if let Err(e) =
crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper)
{
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
}
}
@@ -141,8 +139,7 @@ impl Loader<'_> {
Ok(w) => w,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateFontString (special): {e}"));
.fault(format!("{dbg}: CreateFontString (special): {e}"));
continue;
}
},
@@ -152,9 +149,7 @@ impl Loader<'_> {
self.apply_region_visual(region, &region_wrapper, false, dbg);
// The implicit creation anchor, as in `apply_layers`; an EditBox's adopt replaces it.
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper) {
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
// It is the EditBox's text region by slot, never found by a search, which could take a
// `<Layers>` string; the box's insets anchor it.
@@ -163,8 +158,7 @@ impl Loader<'_> {
crate::script::adopt_text_region(self.lua(), wrapper, &region_wrapper)
{
self.report
.errors
.push(format!("{dbg}: adopt special FontString: {e}"));
.fault(format!("{dbg}: adopt special FontString: {e}"));
}
}
}
@@ -383,9 +377,7 @@ impl Loader<'_> {
height,
outline,
) {
self.report
.errors
.push(format!("{dbg}: region font attrs: {e}"));
self.report.fault(format!("{dbg}: region font attrs: {e}"));
}
}
None => {}
+4 -7
View File
@@ -102,8 +102,7 @@ impl Loader<'_> {
Ok(f) => Some(f),
Err(e) => {
self.report
.errors
.push(format!("{dbg}: compiling <{name}>: {e}"));
.raise(format!("{dbg}: compiling <{name}>: {e}"), &e);
None
}
}
@@ -113,7 +112,7 @@ impl Loader<'_> {
/// (`0x704d50`); an error is recorded, never propagated.
pub(super) fn fire_onload(&mut self, wrapper: &Table, func: &Function, dbg: &str) {
if let Err(e) = self.invoke_handler(wrapper, func) {
self.report.errors.push(format!("{dbg}: OnLoad: {e}"));
self.report.raise(format!("{dbg}: OnLoad: {e}"), &e);
}
}
@@ -126,7 +125,7 @@ impl Loader<'_> {
dbg: &str,
) {
if let Err(e) = wrapper.call_method::<()>(method, args) {
self.report.errors.push(format!("{dbg}: {method}: {e}"));
self.report.fault(format!("{dbg}: {method}: {e}"));
}
}
@@ -139,9 +138,7 @@ impl Loader<'_> {
dbg: &str,
) {
if let Err(e) = region.call_method::<()>(method, args) {
self.report
.errors
.push(format!("{dbg}: region {method}: {e}"));
self.report.fault(format!("{dbg}: region {method}: {e}"));
}
}
}
+3 -7
View File
@@ -165,9 +165,7 @@ impl Loader<'_> {
self.call_region(&region, "ClearAllPoints", (), dbg);
self.apply_region_layout(bt, &region, self_name, dbg, font_attrs);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region) {
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
// Published both ways, like a named state texture.
if let Some(rname) = bt_name {
@@ -232,8 +230,7 @@ impl Loader<'_> {
self.lua, wrapper, which, jh,
) {
self.report
.errors
.push(format!("{dbg}: <{}> justifyH: {e}", f.tag));
.fault(format!("{dbg}: <{}> justifyH: {e}", f.tag));
}
}
// Reported, not modelled: the reference clears the axis on a cross-axis
@@ -561,8 +558,7 @@ impl Loader<'_> {
outline,
) {
self.report
.errors
.push(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
.fault(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
}
}
None => {}
+21 -12
View File
@@ -525,7 +525,10 @@ fn load_bindings(lua: &Lua, name: &str, read: &Reader) {
match crate::bindings_xml::parse(&crate::source::decode(&bytes)) {
Ok(bindings) => super::keybind::register_addon_bindings(lua, name, &bindings),
// Reported, not swallowed: otherwise a broken binding file leaves keys dead silently.
Err(e) => log_error(lua, &format!("{name}/Bindings.xml: {e}")),
Err(e) => {
let msg = format!("{name}/Bindings.xml: {e}");
log_error(lua, &msg, &msg);
}
}
}
@@ -555,7 +558,8 @@ fn load_saved_variables(lua: &Lua, i: usize) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, // the first-run case
Err(e) => {
// Present but unreadable is held, like a file that will not run.
log_error(lua, &format!("{}: {e}", path.display()));
let msg = format!("{}: {e}", path.display());
log_error(lua, &msg, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -570,7 +574,11 @@ fn load_saved_variables(lua: &Lua, i: usize) {
) {
// Deviation: the reference fails silently; we report it, as a player needs to know,
// and hold the file so the shutdown write keeps it.
log_error(lua, &format!("{}: {e}", path.display()));
log_error(
lua,
&format!("{}: {e}", path.display()),
&super::lua_message(&e),
);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -616,7 +624,7 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
};
if lua_file {
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
log_error(lua, &format!("{name}/{file}: {e}"));
log_error(lua, &format!("{name}/{file}: {e}"), &super::lua_message(&e));
}
continue;
}
@@ -630,15 +638,15 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
continue;
}
};
let report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let mut report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let banner = crate::status::file_banner(&install);
report.status.close_into(toc, framexml_debug(lua), banner);
std::mem::take(&mut report.status).close_into(toc, framexml_debug(lua), banner);
// Warnings take the `<Addon>/<file>` prefix; a bare loader warning names no document.
for w in report.warnings {
for w in &report.warnings {
crate::script::diagnostics::record_warning(lua, &format!("{name}/{file}: {w}"));
}
for e in report.errors {
log_error(lua, &format!("{name}/{file}: {e}"));
for (e, heard) in report.raised() {
log_error(lua, &format!("{name}/{file}: {e}"), heard);
}
}
}
@@ -683,12 +691,13 @@ fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
}
/// Record a demand-load error as the startup walk does ([`super::UiScript::report_script_error`]):
/// a `Load` row, dispatched to the player's error handler and put on the host drain.
fn log_error(lua: &Lua, msg: &str) {
/// a `Load` row, dispatched to the player's error handler and put on the host drain. `handler` is
/// the bare Lua message of a raise; a fault that is no raise passes its own text.
fn log_error(lua: &Lua, msg: &str, handler: &str) {
let msg = format!("LoadAddOn: {msg}");
super::diagnostics::record_load_failure(lua, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
model.pending_error_dispatch.push(msg.clone());
model.pending_error_dispatch.push(handler.to_string());
model.errors.push(msg);
}
+1 -1
View File
@@ -998,7 +998,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
if let Err(e) = event::fire_widget_handler(lua, id, "OnClick", vec![btn]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
// A nameplate click selects its unit, from the pointer or `Click()` alike (the plate's
// override `0x7cb910` chains the base), after the handler so an erroring hook cannot eat it. A
+2 -1
View File
@@ -132,9 +132,10 @@ pub(super) fn install(lua: &Lua, locale: &str) -> mlua::Result<()> {
.set_mode(mlua::ChunkMode::Text)
.exec()
{
let bare = super::stdlib::lua_message(&e);
lua.app_data_mut::<crate::script::Model>()
.expect("model")
.record_script_error(super::stdlib::lua_message(&e));
.record_script_error(bare.clone(), bare);
}
Ok(())
})?,
+1 -1
View File
@@ -268,7 +268,7 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -2
View File
@@ -294,7 +294,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
if let Err(e) = on_char {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
if fire_space {
for _ in 0..out.spaces {
@@ -519,7 +519,7 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
@@ -124,7 +124,7 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
true
}
@@ -93,6 +93,19 @@ fn the_flush_follows_the_boxs_own_on_update_cursor_then_text() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The box's place in the walk is its stratum's, not its creation's: a BACKGROUND box updates and
/// flushes before the MEDIUM frames made around it (`0x7656c8`).
#[test]
fn a_box_flushes_after_its_on_update_in_its_strata_place() {
let mut s = script();
logged_box(&mut s);
s.run(r#"E:SetFrameStrata("BACKGROUND") E:SetText("abc")"#)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "update cursor@21 text:abc early late");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An edit a box makes in its own `OnUpdate` is flushed by the same update, so its events fire in
/// the same tick.
#[test]
+2 -2
View File
@@ -38,7 +38,7 @@ pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptVal
if let Err(e) = fire_event_handler(lua, id, event, args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
at = next;
@@ -102,7 +102,7 @@ pub(super) fn fire_size_changes(lua: &Lua) {
) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
}
+2 -2
View File
@@ -124,14 +124,14 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+10 -5
View File
@@ -121,6 +121,8 @@ mod spellbook;
mod stable;
mod statusbar;
mod stdlib;
/// An mlua error's bare Lua message, the one value the error handler is called with.
pub use stdlib::lua_message;
mod summon;
mod talent;
mod taxi;
@@ -131,6 +133,7 @@ mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
mod update_walk;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -1199,14 +1202,16 @@ impl UiScript {
/// Report a script error the host caught outside the VM's dispatch (an addon file that failed
/// to compile or raised at file scope): it joins the handler-dispatch queue and the retained
/// log, not `errors`, since the caller has already logged it.
pub fn report_script_error(&self, msg: &str) {
/// log, not `errors`, since the caller has already logged it. `log` is the decorated text the
/// log keeps; `handler` is the one value the error handler is called with, the bare Lua
/// message as `lua_pcall` leaves it (`0x703b40`).
pub fn report_script_error(&self, log: &str, handler: &str) {
// A Load row, not an Error one: to the player a file scope that raised and a missing file
// are the same fact, the addon is not running.
diagnostics::record_load_failure(&self.lua, msg);
diagnostics::record_load_failure(&self.lua, log);
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
.push(handler.to_string());
}
/// Hand each queued script error to the Lua error handler, as the reference's catch closure
@@ -1263,7 +1268,7 @@ impl UiScript {
}
fn push_error(&self, e: mlua::Error) {
self.model_mut().record_script_error(e.to_string());
self.model_mut().record_lua_error(&e);
}
}
+19 -7
View File
@@ -176,8 +176,11 @@ pub(crate) struct Model {
/// Which handlers each frame has; the closures themselves live in Lua's `REG_SCRIPTS` table.
pub(crate) scripts: HashMap<FrameHandle, HashSet<&'static str>>,
/// Frames with an `OnUpdate` script, kept by `SetScript`; the tick sorts them by frame id.
/// Frames with an `OnUpdate` script, kept by `SetScript`, from which the update walk starts.
pub(crate) on_update_frames: Vec<FrameHandle>,
/// Bumped each time a frame joins [`Self::on_update_frames`], so the walk notices a frame that
/// gains an `OnUpdate` before its turn.
pub(crate) on_update_joins: u64,
/// Frames with an `OnSizeChanged` script, kept by `SetScript`, for the resolve's size snapshot.
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// Frames with an `OnUpdateModel` script, fired as a visible model pane paints (`0x76d1a0`).
@@ -985,13 +988,21 @@ impl Model {
}
}
/// Records a caught script error for the host, the retained log and the Lua error handler; an
/// error raised by the handler itself goes to `errors` directly, which stops the recursion.
pub(crate) fn record_script_error(&mut self, msg: String) {
/// Records a caught script error for the host, the retained log and the Lua error handler: `log`
/// is the decorated text the host and the retained log keep, `handler` the one value the error
/// handler is called with. An error raised by the handler itself goes to `errors` directly,
/// which stops the recursion.
pub(crate) fn record_script_error(&mut self, log: String, handler: String) {
self.diagnostics
.record(super::diagnostics::DiagnosticKind::Error, &msg);
self.pending_error_dispatch.push(msg.clone());
self.errors.push(msg);
.record(super::diagnostics::DiagnosticKind::Error, &log);
self.pending_error_dispatch.push(handler);
self.errors.push(log);
}
/// [`Self::record_script_error`] for an error mlua caught: the log keeps mlua's `Display` (its
/// word and traceback), the handler gets the bare Lua message `lua_pcall` leaves (`0x703b40`).
pub(crate) fn record_lua_error(&mut self, e: &mlua::Error) {
self.record_script_error(e.to_string(), super::stdlib::lua_message(e));
}
/// Records a non-fatal warning for the host's terminal and the retained diagnostic log.
@@ -1090,6 +1101,7 @@ impl Model {
region_names: HashMap::new(),
scripts: HashMap::new(),
on_update_frames: Vec::new(),
on_update_joins: 0,
on_size_changed_frames: Vec::new(),
on_update_model_frames: Vec::new(),
model_facts: HashMap::new(),
+7 -6
View File
@@ -600,12 +600,13 @@ pub(super) fn create_frame(
// this call, so the handler can reach the frame by name.
let wrapper = frame_wrapper(lua, id)?;
if let Some(template) = template {
let messages = crate::loader::apply_template(lua, &wrapper, &kind, &template);
if !messages.is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for m in messages {
model.record_warning(m);
}
let report = crate::loader::apply_template(lua, &wrapper, &kind, &template);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for (log, heard) in report.raised() {
model.record_script_error(log.to_string(), heard.to_string());
}
for w in report.warnings {
model.record_warning(w);
}
}
Ok(wrapper)
@@ -240,7 +240,10 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
// The first handler of a kind joins that kind's list; nothing else writes `scripts`.
if model.scripts.entry(h).or_default().insert(kind) {
match kind {
"OnUpdate" => model.on_update_frames.push(h),
"OnUpdate" => {
model.on_update_frames.push(h);
model.on_update_joins += 1;
}
"OnSizeChanged" => model.on_size_changed_frames.push(h),
"OnUpdateModel" => model.on_update_model_frames.push(h),
_ => {}
+2 -2
View File
@@ -304,7 +304,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
})?,
@@ -327,7 +327,7 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -1
View File
@@ -15,7 +15,8 @@ impl super::UiScript {
// The rest of the line after the alias and one space, spaces intact: the reference's
// `strsub(text, strlen(cmdString) + 2)`.
if let Err(e) = handler.call::<()>(args.to_string()) {
self.model_mut().record_script_error(format!("/{cmd}: {e}"));
self.model_mut()
.record_script_error(format!("/{cmd}: {e}"), super::stdlib::lua_message(&e));
}
true
}
+1 -1
View File
@@ -228,7 +228,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+2 -2
View File
@@ -253,7 +253,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
@@ -274,7 +274,7 @@ pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHand
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+12 -4
View File
@@ -11,12 +11,20 @@
use mlua::{Lua, Value, Variadic};
/// An mlua error's bare Lua message, as `loadstring` returns it: without the `syntax error: ` or
/// `runtime error: ` word mlua's `Display` adds and the reference never does.
pub(super) fn lua_message(e: &mlua::Error) -> String {
/// An mlua error's bare Lua message: what `lua_pcall` leaves for a 1.12 error function (`0x703b40`)
/// and what `loadstring` returns. It drops the `syntax error: ` and `runtime error: ` words mlua's
/// `Display` adds, the `stack traceback:` block mlua's message handler appends to a raise, and the
/// wrappers a Rust callback's failure arrives in.
pub fn lua_message(e: &mlua::Error) -> String {
match e {
mlua::Error::SyntaxError { message, .. } => message.clone(),
mlua::Error::RuntimeError(m) => m.clone(),
mlua::Error::RuntimeError(m) => m
.rfind("\nstack traceback:\n")
.map_or(m.as_str(), |at| &m[..at])
.to_string(),
mlua::Error::CallbackError { cause, .. } | mlua::Error::WithContext { cause, .. } => {
lua_message(cause)
}
other => other.to_string(),
}
}
@@ -464,3 +464,41 @@ fn a_runtime_template_fires_its_onshow_after_its_onload_when_visible() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A template's OnLoad runs under the error function as a file's does (`CreateFrame` builds through
/// `0x6ee280`, OnLoad through `0x704d50`): a raise reaches the chosen error handler as the bare
/// message, and `CreateFrame` still returns the frame.
#[test]
fn a_raising_template_onload_reaches_the_error_handler() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
register(
&s,
r#"<Ui>
<Frame name="RaisingTemplate" virtual="true">
<Scripts><OnLoad>this:NoSuchMethod()</OnLoad></Scripts>
</Frame>
</Ui>"#,
);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
made = CreateFrame('Frame', 'Raised', UIParent, 'RaisingTemplate')",
)
.unwrap();
s.dispatch_script_errors_to_handler();
let caught: Vec<String> = s.eval("return caught").unwrap();
assert_eq!(caught.len(), 1, "{caught:?}");
assert!(
caught[0].ends_with("attempt to call method `NoSuchMethod' (a nil value)")
&& !caught[0].contains('\n'),
"{:?}",
caught[0]
);
let made: bool = s
.eval("return made ~= nil and made:GetName() == 'Raised'")
.unwrap();
assert!(
made,
"CreateFrame returns the frame after its OnLoad raised"
);
}
+46
View File
@@ -52,6 +52,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod toplevel;
mod update_walk;
mod visibility;
mod widget_surface;
mod worldframe;
@@ -221,6 +222,51 @@ fn a_widget_handler_error_reaches_the_chosen_error_handler() {
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
}
/// 1.12 calls the error handler with the one value the failed call raised (`0x703b40`: the
/// handler pushed, inserted below the message, `lua_call` 1 argument): the bare Lua message, no
/// `runtime error: ` word and no traceback, which only benilla's own log carries.
#[test]
fn the_error_handler_receives_the_bare_lua_message() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
local f = CreateFrame('Frame') \
f:RegisterEvent('ERROR_PROBE') \
f:SetScript('OnEvent', function() this:NoSuchMethod() end) \
local b = CreateFrame('Button') \
b:SetScript('OnClick', function() local t = nil; t.x = 1 end) \
b:Click() \
local c = CreateFrame('Button') \
c:SetScript('OnClick', function() SetPortraitTexture() end) \
c:Click()",
)
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
let caught: Vec<String> = s.eval("return caught").unwrap();
assert!(caught.len() >= 3, "{caught:?}");
for m in &caught {
assert!(
!m.contains("runtime error") && !m.contains("stack traceback") && !m.contains('\n'),
"bare message, one line: {m:?}"
);
}
for want in [
"attempt to call method `NoSuchMethod' (a nil value)",
":1: attempt to index local `t' (a nil value)",
] {
assert!(
caught
.iter()
.any(|m| m.starts_with("[string \"") && m.ends_with(want)),
"{want}: {caught:?}"
);
}
// The host log keeps the decorated text.
assert!(s.take_errors().iter().any(|e| e.contains("runtime error")));
}
/// `fire_global` carries `UPDATE_FACTION` from the faction-header verbs.
#[test]
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
@@ -0,0 +1,216 @@
//! The update walk (`0x765650`): strata from the bottom up (`0x7656c8`–`0x765793`), each
//! stratum's levels from 0 up (`0x765774`), and each level's list of linked frames oldest link
//! first, the list being head-inserted (`0x7648c9`) and walked from its tail (`0x7656e5`–`0x765707`).
//! The walk saves the next frame before each call (`0x765707`), a hide repairs that cursor
//! (`0x76498f`), and a frame linked into the level being walked is staged until the level ends
//! (`0x76489b`, `0x76573e`).
use crate::script::UiScript;
use super::common::script;
/// `LOG` joined by spaces, then emptied.
fn take_log(s: &UiScript) -> String {
s.eval::<String>("local l = table.concat(LOG, ' ') LOG = {} return l")
.unwrap()
}
/// Lua that makes a frame `name`, logging `tag` from its `OnUpdate`.
const LOGGED: &str = r#"
LOG = {}
function Logged(name, tag)
local f = CreateFrame("Frame", name)
f:SetScript("OnUpdate", function() table.insert(LOG, tag) end)
return f
end
"#;
#[test]
fn the_walk_runs_strata_then_level_not_creation_order() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("WalkTip", "tooltip"):SetFrameStrata("TOOLTIP")
Logged("WalkBack", "background"):SetFrameStrata("BACKGROUND")
Logged("WalkHigh", "level5"):SetFrameLevel(5)
Logged("WalkLow", "level1"):SetFrameLevel(1)
"#,
)
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "background level1 level5 tooltip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Within a level the oldest link goes first, and a show links at the end: a frame hidden and
/// shown again walks after one created later.
#[test]
fn a_reshown_frame_walks_after_its_levels_older_links() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("LinkA", "a")
Logged("LinkB", "b")
LinkA:Hide()
LinkA:Show()
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "b a");
}
#[test]
fn a_frame_hidden_by_an_earlier_on_update_is_not_updated() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("HideA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
HideB:Hide()
end)
Logged("HideB", "b")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A frame hidden and shown again before its turn relinks into the level being walked, which
/// stages it: it waits for the next tick. (A blink every tick would stage it every tick.)
#[test]
fn a_frame_reshown_before_its_turn_in_the_walked_level_waits_a_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("BlinkA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
if not BLINKED then
BLINKED = 1
BlinkB:Hide()
BlinkB:Show()
end
end)
Logged("BlinkB", "b")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a");
s.tick(0.016);
assert_eq!(take_log(&s), "a b");
}
/// A frame shown into a level the walk has not reached yet is updated that tick; one shown into
/// the level being walked or one already walked waits for the next.
#[test]
fn a_frame_shown_mid_walk_is_updated_only_if_its_level_is_still_ahead() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("ShowA", "a"):SetScript("OnUpdate", function()
table.insert(LOG, "a")
ShowTip:Show()
ShowSame:Show()
ShowBack:Show()
end)
Logged("ShowTip", "tip"):SetFrameStrata("TOOLTIP")
Logged("ShowSame", "same")
Logged("ShowBack", "back"):SetFrameStrata("BACKGROUND")
ShowTip:Hide()
ShowSame:Hide()
ShowBack:Hide()
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "a tip");
s.tick(0.016);
assert_eq!(take_log(&s), "back a same tip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A level change relinks a shown frame at the head of its new level (`0x76a54c`), so a frame that
/// moves itself to a level still ahead is walked again there.
#[test]
fn a_frame_that_moves_itself_to_a_level_ahead_is_walked_again() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("Mover", "m"):SetScript("OnUpdate", function()
table.insert(LOG, "m" .. this:GetFrameLevel())
if this:GetFrameLevel() == 0 then this:SetFrameLevel(3) end
end)
Logged("Mid", "mid"):SetFrameLevel(2)
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "m0 mid m3");
s.tick(0.016);
assert_eq!(take_log(&s), "mid m3");
}
/// The update reads the frame's handler when its turn comes (`0x76b2c3`), so an `OnUpdate` set by
/// an earlier frame's runs that tick.
#[test]
fn an_on_update_set_before_the_frames_turn_runs_that_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
Logged("Setter", "setter"):SetScript("OnUpdate", function()
table.insert(LOG, "setter")
if not SET then
SET = true
Earlier:SetScript("OnUpdate", function() table.insert(LOG, "earlier") end)
Later:SetScript("OnUpdate", function() table.insert(LOG, "later") end)
end
end)
CreateFrame("Frame", "Earlier"):SetFrameStrata("BACKGROUND")
CreateFrame("Frame", "Later"):SetFrameStrata("HIGH")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "setter later");
s.tick(0.016);
assert_eq!(take_log(&s), "earlier setter later");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// "Ahead" is by stratum first: a frame shown into a later stratum is updated that tick even at a
/// lower level than the one being walked, as the reference walks every stratum after this one.
#[test]
fn a_frame_shown_into_a_later_stratum_at_a_lower_level_runs_that_tick() {
let mut s = script();
s.run(LOGGED).unwrap();
s.run(
r#"
local late = Logged("LateHigh", "high")
late:SetFrameStrata("HIGH")
late:SetFrameLevel(1)
late:Hide()
local shower = Logged("ShowerMed", "medium")
shower:SetFrameLevel(5)
shower:SetScript("OnUpdate", function()
table.insert(LOG, "medium")
LateHigh:Show()
end)
"#,
)
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "medium high");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+8 -75
View File
@@ -5,7 +5,7 @@ use mlua::Lua;
use crate::widget::FrameHandle;
use super::{editbox, event, tooltip, Model, ScriptValue, UiScript};
use super::{event, tooltip, Model, ScriptValue, UiScript};
impl UiScript {
/// Queue an event for the start of the next tick, behind everything queued earlier and before
@@ -61,7 +61,7 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
let id = model.frame_id(h);
drop(model);
if let Err(e) = event::fire_event_handler(lua, id, event, &args) {
model_mut().record_script_error(e.to_string());
model_mut().record_lua_error(&e);
}
at = next;
}
@@ -70,10 +70,10 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
}
impl super::UiScript {
/// Advance a frame: the `GetTime()` clock and queued events, then the walk (`0x765650`),
/// each shown frame's update in turn: its `OnUpdate(self, elapsed)` (`0x704f10`), and for an
/// EditBox its flush and caret blink after it; then the engine's own fades, model panes and
/// hover.
/// Advance a frame: the `GetTime()` clock and queued events, then the update walk
/// (`0x765650`), each linked frame's update in strata, level and link order: its
/// `OnUpdate(self, elapsed)` (`0x704f10`), and for an EditBox its flush and caret blink after
/// it; then the engine's own fades, model panes and hover.
pub fn tick(&mut self, elapsed: f32) {
self.model_mut().now += f64::from(elapsed);
// Events queued since the last tick fire before this tick's OnUpdate.
@@ -81,74 +81,7 @@ impl super::UiScript {
for (event, args) in pending {
self.fire_event(&event, args);
}
// Each entry: the frame's id, whether it has an `OnUpdate`, and the box if an EditBox.
let walk: Vec<(u32, bool, Option<FrameHandle>)> = {
let mut model = self.model_mut();
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
let shown = |h: FrameHandle| model.arena.frame(h).is_some_and(|f| f.effective_visible);
let mut frames: Vec<(FrameHandle, bool)> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| shown(h))
.map(|h| (h, false))
.collect();
// The walk reaches every shown frame, not only those with a script, and an EditBox's
// update (`0x77a790`, vtable `+0x38`) flushes it whether or not it has an `OnUpdate`.
frames.extend(
model
.arena
.editbox_kinds()
.iter()
.copied()
.filter(|&h| shown(h))
.map(|h| (h, true)),
);
if model
.on_update_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
let mut walk: Vec<(u32, bool, Option<FrameHandle>)> = frames
.into_iter()
.map(|(h, editbox)| (model.frame_id(h), !editbox, editbox.then_some(h)))
.collect();
// Creation order, by frame id: getters settle on demand, so a handler sees what an
// earlier one did this sweep, and the order must be stable. The reference walks each
// strata level's list of shown frames (`0x765650`). A box with an `OnUpdate` is
// listed twice, and the two entries merge.
walk.sort_unstable_by_key(|&(id, ..)| id);
walk.dedup_by(|later, earlier| {
let same = later.0 == earlier.0;
if same {
earlier.1 |= later.1;
earlier.2 = earlier.2.or(later.2);
}
same
});
walk
};
for (id, on_update, editbox) in walk {
if on_update {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
self.push_error(e);
}
}
// The box's own update runs its flush after its `OnUpdate` (`0x77a79a`, then
// `0x77a7a1`), so the events an edit raised fire in the box's place in the walk.
if let Some(h) = editbox {
editbox::update(&self.lua, h, elapsed);
}
}
self.update_walk(elapsed);
self.tick_model_panes(elapsed);
// The engine's line fades, apart from any OnUpdate script: ScrollingMessageFrame's
// (`0x788460`) and MessageFrame's (`0x786200`), which also keeps only the lines that fit
@@ -255,7 +188,7 @@ impl UiScript {
})
.collect();
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
ids.sort_unstable(); // creation order, as in the OnUpdate sweep
ids.sort_unstable(); // creation order
ids
};
for id in update_ids {
+1 -1
View File
@@ -313,7 +313,7 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
if let Err(e) = event::fire_widget_handler(lua, id, "OnTooltipCleared", Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
@@ -114,7 +114,7 @@ fn fire_add_money(lua: &Lua, h: crate::widget::FrameHandle, copper: u64) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
+1 -1
View File
@@ -226,7 +226,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The update walk (`0x765650`), each linked frame's per-frame update in the reference's order: the
//! nine strata from the bottom up (`0x7656c8`–`0x765793`), each stratum's levels from 0 up
//! (the bound re-read after each level, `0x765774`), and each level's list of linked frames from its
//! tail (`0x7656e5`–`0x765707`). Every link is a head insert (`0x7648c9`), so a level walks oldest
//! link first: the order of [`Frame::insertion_seq`](crate::widget::Frame::insertion_seq).
//!
//! The walk is live. It saves the next frame before each call (`0x765707`), and a hide that
//! unlinks that frame moves the cursor past it (`0x76498f`), so a frame hidden before its turn is
//! not updated. A frame linked while its own level is being walked is staged until that level
//! ends (`0x76489b`, spliced at `0x76573e`) and so waits a tick, as does one linked into a level
//! already walked; one linked into a level still ahead is updated there, after that level's
//! older links.
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::widget::{FrameHandle, FrameKind};
use super::{editbox, event, UiScript};
/// A place in the walk: stratum, level, then within the level the frames linked before the walk
/// began (0, by link stamp) ahead of those linked during it (1, by the order they linked).
type Place = (u8, u16, u8, u64);
impl UiScript {
/// Run the walk: each frame's `OnUpdate(self, elapsed)` (`0x76b2c0`, reading the handler when
/// its turn comes), and for an EditBox its flush after it (`0x77a79a`, then `0x77a7a1`), so the
/// events an edit raised fire in the box's place.
pub(super) fn update_walk(&mut self, elapsed: f32) {
let mut ahead: BTreeMap<Place, FrameHandle> = BTreeMap::new();
let (renumbers, mut joins, mut seen) = {
let mut model = self.model_mut();
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
if model
.on_update_frames
.iter()
.any(|&h| model.arena.frame(h).is_none())
{
let arena = &model.arena;
let live: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
// Only frames with an `OnUpdate` or an EditBox's flush do anything when walked.
let mut seen = HashSet::new();
for &h in model
.on_update_frames
.iter()
.chain(model.arena.editbox_kinds())
{
if let Some(f) = model.arena.frame(h).filter(|f| f.effective_visible) {
let place = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq));
ahead.insert(place, h);
seen.insert(h);
}
}
model.arena.open_link_log();
(model.arena.renumbers(), model.on_update_joins, seen)
};
// Each frame linked during the walk, by the number of its latest link.
let mut relinked: HashMap<FrameHandle, u64> = HashMap::new();
let mut links = 0u64;
while let Some((place, h)) = ahead.pop_first() {
let (on_update, editbox) = {
let mut model = self.model_mut();
let Some(f) = model.arena.frame(h) else {
continue;
};
// Still at this place: linked, and not relinked since the place was taken.
let here = f.effective_visible
&& match place.2 {
0 => !relinked.contains_key(&h),
_ => relinked.get(&h) == Some(&place.3),
};
let editbox = f.kind == FrameKind::EditBox;
let on_update = here
&& model
.scripts
.get(&h)
.is_some_and(|s| s.contains("OnUpdate"));
(on_update.then(|| model.frame_id(h)), here && editbox)
};
if let Some(id) = on_update {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
self.push_error(e);
}
}
if editbox {
editbox::update(&self.lua, h, elapsed);
}
let mut model = self.model_mut();
for linked in model.arena.take_linked() {
links += 1;
relinked.insert(linked, links);
let Some(f) = model.arena.frame(linked) else {
continue;
};
if (f.strata as u8, f.level) > (place.0, place.1) {
ahead.insert((f.strata as u8, f.level, 1, links), linked);
}
}
// A frame that gained an `OnUpdate` keeps its place if the walk has not passed it. Its
// stamp compares with the walk's only while no renumber came between; else it waits.
if model.on_update_joins != joins {
joins = model.on_update_joins;
if model.arena.renumbers() == renumbers {
let joined: Vec<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|h| !seen.contains(h) && !relinked.contains_key(h))
.collect();
for j in joined {
seen.insert(j);
let Some(f) = model.arena.frame(j).filter(|f| f.effective_visible) else {
continue;
};
let at = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq));
if at > place {
ahead.insert(at, j);
}
}
}
}
}
self.model_mut().arena.close_link_log();
}
}
+1 -1
View File
@@ -152,6 +152,6 @@ fn model_mut(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
/// A handler error is recorded in [`Model::errors`], never propagated to the verb.
fn record(lua: &Lua, fired: mlua::Result<()>) {
if let Err(e) = fired {
model_mut(lua).record_script_error(e.to_string());
model_mut(lua).record_lua_error(&e);
}
}
+42
View File
@@ -250,6 +250,12 @@ pub struct WidgetArena {
regions: Arena<Region>,
names: HashMap<String, FrameHandle>,
next_insertion: u32,
/// How many times the link stamps were renumbered at the cap, so a reader holding stamps can
/// tell they are still comparable with the live ones.
renumbers: u32,
/// While the update walk runs, every frame linked into a bucket since it opened, in link order
/// ([`Self::open_link_log`]).
link_log: Option<Vec<FrameHandle>>,
/// The draw list's fingerprint cache ([`crate::order::traversal`]).
pub(crate) order_cache: crate::order::OrderCache,
/// Minimap frames ever created, so the state feed notices a new one without a walk.
@@ -301,6 +307,8 @@ impl WidgetArena {
regions: Arena::new(),
names: HashMap::new(),
next_insertion: 0,
renumbers: 0,
link_log: None,
minimap_created: 0,
order_cache: Default::default(),
ticked_kinds: Vec::new(),
@@ -381,14 +389,42 @@ impl WidgetArena {
}
}
self.next_insertion = order.len() as u32;
self.renumbers += 1;
}
let seq = self.next_insertion;
if let Some(f) = self.frame_mut(h) {
f.insertion_seq = seq;
self.next_insertion += 1;
if let Some(log) = &mut self.link_log {
log.push(h);
}
}
}
/// Start recording every link into a bucket, for the update walk (`0x765650`), which must see
/// the frames a handler links while it runs.
pub(crate) fn open_link_log(&mut self) {
self.link_log = Some(Vec::new());
}
/// The frames linked since the last call, in link order, the log staying open.
pub(crate) fn take_linked(&mut self) -> Vec<FrameHandle> {
self.link_log
.as_mut()
.map(std::mem::take)
.unwrap_or_default()
}
/// Stop recording links.
pub(crate) fn close_link_log(&mut self) {
self.link_log = None;
}
/// How many times the link stamps have been renumbered; stamps read under one count compare.
pub(crate) fn renumbers(&self) -> u32 {
self.renumbers
}
/// The first frame created with `name`; a later duplicate never takes it over (`0x701bd0`).
pub fn lookup(&self, name: &str) -> Option<FrameHandle> {
self.names.get(name).copied()
@@ -502,8 +538,14 @@ impl WidgetArena {
| FrameKind::DressUpModel
| FrameKind::TabardModel
);
let visible = frame.effective_visible;
let (index, generation) = self.frames.insert(frame);
let handle = FrameHandle { index, generation };
if visible {
if let Some(log) = &mut self.link_log {
log.push(handle);
}
}
if ticked {
self.ticked_kinds.push(handle);
}
+2
View File
@@ -15,6 +15,8 @@ dev = ["benilla-app/dev"]
# Profiling switches, forwarded so `cargo run -p benilla --features tracy` works.
tracy = ["benilla-app/tracy"]
trace_chrome = ["benilla-app/trace_chrome"]
# Forwards to benilla-app's `bevy_dynamic_linking`, which enables bevy/dynamic_linking.
bevy_dynamic_linking = ["benilla-app/bevy_dynamic_linking"]
[dependencies]
# `default-features = false`, or `--no-default-features` here still builds benilla-app with `dev`.
+2 -1
View File
@@ -110,7 +110,8 @@ run() {
run fmt cargo fmt --all -- --check
run clippy cargo clippy $pflags --all-targets -- -D warnings
run test env BENILLA_SKIP_LOG="$skips" cargo test $pflags
# --no-fail-fast: one red test binary does not hide the failures in the ones after it.
run test env BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast $pflags
# Data-gated tests skip without the install or the addon corpus and libtest swallows their line,
# so each skip is also logged to $BENILLA_SKIP_LOG and counted here.
if [ -s "$skips" ]; then
+2 -2
View File
@@ -120,12 +120,12 @@ else
"corpus=$([ -d "$root/wow-addons-vanilla" ] && echo yes || echo no)):" \
"data-gated tests skip silently on this machine"
fi
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --workspace
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast --workspace
report_skips test
# test-no-install: refuses a test that reads the install without `wow_data_or_skip!`. `WOW_DATA=`
# (set, empty) is the resolver's "no install"; no build reads it, so the test binaries are reused.
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --workspace
run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --no-fail-fast --workspace
# doc-links: refuses a doc link to a `crate::`, `super::`, `self::` or `Self::` path whose leaf is
# declared nowhere in the workspace. Narrow on purpose: every other unresolved link is a convention.