diff --git a/Cargo.lock b/Cargo.lock index eef0a87fd..dae826cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 7dd1c49f8..de8d45eb1 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -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-.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 diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 5ba49f7de..cdbf2117e 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -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> { - 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> { - 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 `` 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#"this:NoSuchMethod() +"#, + ) + .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 = 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); + } } diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 9f759fef8..843dacae2 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index f114e1a58..fdb660637 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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()]); } } diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index fe66caf8e..7ed4e42d1 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -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 { 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::>()[..], + [std::path::Component::Normal(_)] + ); + if !plain || comp.starts_with('.') { return None; } let direct = at.join(comp); diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs index 9b22381ae..b7d18fad8 100644 --- a/crates/benilla-ui/src/loader/backdrop.rs +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -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}: : {e}")); + self.report.fault(format!("{dbg}: : {e}")); return; } }; diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 84d3a7560..94925791d 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -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; } }; diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 238d4db66..2be047272 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -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 { + 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, + /// 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, /// `` and `