diff --git a/Cargo.lock b/Cargo.lock index 7afa1b73..f5c9af40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" dependencies = [ + "bevy_dylib", "bevy_internal", "tracing", ] @@ -1015,6 +1016,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/Cargo.toml b/Cargo.toml index aa2440d7..8ef32dbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,9 +49,13 @@ kira = { path = "third_party/kira" } opt-level = 3 debug = "line-tables-only" split-debuginfo = "unpacked" +# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's +# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold +# build is no slower. +codegen-units = 1 # Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0 -# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0. +# Stormwind drops from a pinned 60 fps to ~45. [profile.dev] opt-level = 1 # Line tables only, not `debug = 2`. DWARF is ~97% of what rustc writes for this tree: the largest @@ -66,6 +70,10 @@ opt-level = 1 # with sccache and no disk pressure. Re-measure on macOS before assuming it holds there. debug = "line-tables-only" split-debuginfo = "unpacked" +# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an +# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its +# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin. +lto = "off" # The shipping build (`--profile ship`): fat LTO measures -1.0 cpu_ms (-5 %) against `release`, # which stays the measured profile because fat LTO's link time would tax every iteration. @@ -84,3 +92,8 @@ incremental = true # Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols # unresolved and the link fails intermittently (rustc #86049). lto = "off" + +# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at +# the leg pin, at one the profile is +0.5, and a cold build is no slower. +[profile.play.package."*"] +codegen-units = 1 diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 4d658f21..ce77d084 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -21,6 +21,9 @@ dev = [ 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/assets/ui/AccountLoginRealmlist.xml b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml new file mode 100644 index 00000000..fb7bcac5 --- /dev/null +++ b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml @@ -0,0 +1,127 @@ + + + + + diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index e6f642bf..72283f9c 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -278,11 +278,29 @@ return bottom end + -- The scroll frame's size in its own units, from its edges: its anchors size it, and + -- GetWidth answers such a frame's span before the window's scale divides it. + local function BenillaOptionsScroll_Size(sf) + local l, r, t, b = sf:GetLeft(), sf:GetRight(), sf:GetTop(), sf:GetBottom() + if not l or not r or not t or not b then return nil end + return r - l, t - b + end + + -- The page's range is the body's overhang, the content Fit measures: the engine's range + -- also counts the Keybindings list's faux scroll child, which is sized to every row. + function BenillaOptionsScroll_Range(sf) + local _, fh = BenillaOptionsScroll_Size(sf) + if not fh then return 0 end + local range = BenillaOptionsFrameContainerBody:GetHeight() - fh + if range > 0 then return range end + return 0 + end + -- From OnUpdate, off the previous frame's rects; SetWidth and SetHeight invalidate layout. function BenillaOptionsScroll_Fit() local sf = BenillaOptionsFrameContainerScroll local body = BenillaOptionsFrameContainerBody - local fw, fh = sf:GetWidth(), sf:GetHeight() + local fw, fh = BenillaOptionsScroll_Size(sf) if not fw or fw <= 0 or not fh or fh <= 0 then return end if math.abs(body:GetWidth() - fw) >= 0.5 then body:SetWidth(fw) end local h = fh @@ -294,6 +312,9 @@ if math.abs(body:GetHeight() - h) >= 0.5 then body:SetHeight(h) sf:UpdateScrollChildRect() + -- The bar follows the body, which the range notify misses when the engine's range, + -- a whole-subtree union, does not move. + BenillaScroll_SyncBar(sf) end end @@ -2331,7 +2352,10 @@ - this:SetScrollChild(BenillaOptionsFrameContainerBody) + + this:SetScrollChild(BenillaOptionsFrameContainerBody) + this.contentRange = BenillaOptionsScroll_Range + BenillaOptionsScroll_Fit() diff --git a/crates/benilla-app/assets/ui/ScrollTemplates.xml b/crates/benilla-app/assets/ui/ScrollTemplates.xml index 19a3f3e8..d8fdd234 100644 --- a/crates/benilla-app/assets/ui/ScrollTemplates.xml +++ b/crates/benilla-app/assets/ui/ScrollTemplates.xml @@ -96,11 +96,18 @@ BenillaScrollBar_UpdateArrows(bar) end + -- The range the bar follows: the engine's, the reference's union of the child's whole + -- subtree, unless the frame measures its own content (`sf.contentRange`). + function BenillaScroll_Range(sf) + if sf.contentRange then return sf.contentRange(sf) end + return sf:GetVerticalScrollRange() + end + -- Hides the bar when the content fits (reference: ScrollFrame_OnScrollRangeChanged). function BenillaScroll_SyncBar(sf) local bar = getglobal(sf:GetName() .. "Bar") if not bar then return end - local range = sf:GetVerticalScrollRange() + local range = BenillaScroll_Range(sf) bar:SetValueStep(BENILLA_SCROLL_STEP) bar:SetMinMaxValues(0, range) bar:SetValue(sf:GetVerticalScroll()) @@ -111,7 +118,7 @@ -- Clamped here: SetVerticalScroll stores any value, as the reference's does. function BenillaScroll_Step(sf, delta) local target = sf:GetVerticalScroll() + delta - local range = sf:GetVerticalScrollRange() + local range = BenillaScroll_Range(sf) if target > range then target = range end if target < 0 then target = 0 end sf:SetVerticalScroll(target) diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 3a48574c..e1d3021f 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -162,7 +162,7 @@ fn corpus( let toc = Toc::parse(&benilla_ui::source::decode( &std::fs::read(manifest_path(root, n)?).unwrap_or_default(), )); - Some(crate::ui_script::addons::info_from_toc(n, &toc)) + Some(crate::ui_script::addons::info_from_toc(n, &toc, false)) }) .collect(); @@ -264,15 +264,10 @@ fn survey_one( // The addon's dependencies, first and recursively, as `AddOn_Load` (`0x51f240`) does in its // first two steps. Loaded before `globals_of` below, so a name a dependency provides is not // missing. - let mut dep_loads: Vec = Vec::new(); - load_dependencies( - &mut script, - root, - &toc, - installed, - &mut BTreeSet::new(), - &mut dep_loads, - ); + // Marked first, as `AddOn_Load` marks an addon before its dependency loops (`0x51f313`): a + // dependency that requires the surveyed addon finds it loaded and does not load it here. + script.mark_addon_loaded(name); + let dep_loads = load_dependencies(&script, root, registry, &toc); let dep_order: Vec = dep_loads.into_iter().map(|d| d.name).collect(); let known = globals_of(&script); // The methods the dependency chain defines, read from its source (a method has no VM read). @@ -289,15 +284,11 @@ fn survey_one( let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq); let files = load_addon_files(&script, root, name, &toc); - // The live walk stamps each addon as loaded just before its `ADDON_LOADED` (`0x51f5ad`); - // without it `IsAddOnLoaded` answers nil and a LoadOnDemand dependent gets - // `DEP_NOT_DEMAND_LOADED`. The dependencies stamped themselves in `load_dependencies`. - script.mark_addon_loaded(name); let wants = missing_calls(root, name, &toc, &known, &dep_methods); // After the addon's files, so a template its own XML declares is registered. let missing_templates = missing_templates(&script, root, name, &toc); let missing_inherits = missing_inherits(&script, root, name, &toc); - let session_errors = drive_session_start(&mut script, name, installed); + let session_errors = drive_session_start(&mut script, name, installed, root, registry); let probe_errors = drive_ui_probe(&mut script); // Warnings only; errors have their own columns. let warnings: Vec = script @@ -576,6 +567,8 @@ fn drive_session_start( script: &mut UiScript, name: &str, installed: &BTreeSet, + root: &Path, + registry: &[benilla_ui::script::AddOnInfo], ) -> Vec { // Errors are attributed by the raising chunk, not the event window: AceAddon runs every queued // consumer's `OnInitialize` on any `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`, `:230`), @@ -587,6 +580,15 @@ fn drive_session_start( "ADDON_LOADED", vec![benilla_ui::script::ScriptValue::Str(name.to_string())], ); + // `AddOn_Load` goes on to the addons whose `## LoadWith` names this one (`0x51f5b2`- + // `0x51f5e1`), their files and events inside this window; a raise in their own files is + // exempted below by its chunk name, like a dependency's. + if let Some(row) = script.addon_row(name) { + let mut host = HarnessLoad::new(script, root, registry); + for dependent in script.addon_dependents(row) { + script.load_addon_row(&mut host, dependent, false, false); + } + } for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] { script.fire_event(event, Vec::new()); } @@ -726,54 +728,75 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec { out } -/// Load an addon's declared dependencies into the VM, depth-first, each at most once. -/// -/// The 1.12 client's order (`AddOn_Load`, `0x51f240`): optional dependencies first, failures -/// ignored; required ones next, a failure aborting the dependent; then its own files. The abort is -/// not reproduced, only the state the dependent's file scope meets; a missing required dependency -/// is reported ([`AddonReport::missing_deps`]), an absent optional one is silent. Errors inside a -/// dependency are its own row, never the dependent's. +/// Load an addon's declared dependencies into the VM through the shared `AddOn_Load` driver +/// (`0x51f240`): optional ones first, failures ignored, then required ones, each through the gate, +/// its own dependencies first, its files, then `ADDON_LOADED` with its folder name, then the +/// addons whose `## LoadWith` names it. The surveyed addon's own gate and abort are not +/// reproduced, so a survey still measures it when a required dependency is missing; that is +/// reported ([`AddonReport::missing_deps`]). Errors inside a dependency are its own row, never the +/// dependent's. The caller marks the surveyed addon loaded first (`0x51f313`). fn load_dependencies( - script: &mut UiScript, + script: &UiScript, root: &Path, + registry: &[benilla_ui::script::AddOnInfo], toc: &Toc, - installed: &BTreeSet, - seen: &mut BTreeSet, - loaded: &mut Vec, -) { +) -> Vec { // Optional first, then required: `FuBar_BagFu` lists `FuBarPlugin-2.0.lua` before // `AceLibrary.lua`, and FuBarPlugin raises unless the optional `Ace2` loaded first. - let deps: Vec<&str> = toc + let mut host = HarnessLoad::new(script, root, registry); + for dep in toc .optional_dependencies() .into_iter() .chain(toc.dependencies()) - .collect(); - for dep in deps { - let key = dep.to_ascii_lowercase(); - if !installed.contains(&key) || !seen.insert(key) { - continue; // not installed (already reported), or already in this VM + { + if let Some(row) = script.addon_row(dep) { + // Every addon loads: the harness forces past the version check, as the AddOns screen's + // Load out of date AddOns does. + script.load_addon_row(&mut host, row, false, false); } - let Some(dep_toc) = manifest_path(root, dep) + } + host.loaded +} + +/// The harness's side of `AddOn_Load`: run a registry row's `.toc` files out of the corpus folder +/// and keep what each did. Rows are the corpus's, in [`corpus`]'s order. +struct HarnessLoad<'a> { + script: &'a UiScript, + root: &'a Path, + registry: &'a [benilla_ui::script::AddOnInfo], + /// Every addon the driver ran, in load order. + loaded: Vec, +} + +impl<'a> HarnessLoad<'a> { + fn new( + script: &'a UiScript, + root: &'a Path, + registry: &'a [benilla_ui::script::AddOnInfo], + ) -> Self { + HarnessLoad { + script, + root, + registry, + loaded: Vec::new(), + } + } +} + +impl benilla_ui::script::AddonLoad for HarnessLoad<'_> { + fn run(&mut self, i: usize) { + let name = &self.registry[i].name; + let Some(toc) = manifest_path(self.root, name) .and_then(|p| std::fs::read(p).ok()) .map(|b| Toc::parse(&benilla_ui::source::decode(&b))) else { - continue; + return; }; - load_dependencies(script, root, &dep_toc, installed, seen, loaded); - let files = load_addon_files(script, root, dep, &dep_toc); - // `ADDON_LOADED` fires here, at the 1.12 client's position: `AddOn_Load` (`0x51f240`) runs - // an addon's dependencies, its `.toc` files (`0x51f3fa`), `Bindings.xml`, the two - // SavedVariables chunks, then `ADDON_LOADED` (event 429, `0x51f5ad`), before the next - // addon. The loaded flag is set first (`[rec+0x18]=1` at `0x51f313`), so a handler's - // `IsAddOnLoaded` is true. Fired outside the surveyed addon's error mark: a library raising - // in its initialiser is its own row. - script.mark_addon_loaded(dep); - script.fire_event( - "ADDON_LOADED", - vec![benilla_ui::script::ScriptValue::Str(dep.to_string())], - ); - loaded.push(LoadedDep { - name: dep.to_string(), + // `ADDON_LOADED` follows from the driver, after the files (`0x51f5ad`); a raise in a + // library's initialiser is its own row, outside the surveyed addon's error mark. + let files = load_addon_files(self.script, self.root, name, &toc); + self.loaded.push(LoadedDep { + name: name.clone(), files, }); } @@ -1397,6 +1420,19 @@ struct FileLoad { /// FrameXML, in the install-relative path space (`Interface/AddOns/`, as /// `ui_script::addons::Addon::prefix`), so chunk names match a live session's. fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad { + // Under the addon's own context, as the live walk loads it. + let outer = script.swap_exec_context(benilla_ui::script::addon_load_context( + name, + // The corpus is folder addons, never the chain. + crate::ui_script::addons::secure(false, toc), + )); + let load = load_addon_files_in_context(script, root, name, toc); + script.swap_exec_context(outer); + load +} + +/// [`load_addon_files`]' body. +fn load_addon_files_in_context(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad { let provider = |req: &str| -> Option> { read_under(root, req) }; let base = addon_base(name); let mut errors = Vec::new(); @@ -2636,6 +2672,134 @@ mod dependency_tests { let _ = std::fs::remove_dir_all(&tmp); } + /// A folder of addons under a temp root, `(name, toc, lua body)` each with one file `main.lua`; + /// every body starts by appending its folder to `Order`. + fn corpus_folder(tag: &str, addons: &[(&str, &str, &str)]) -> PathBuf { + let tmp = + std::env::temp_dir().join(format!("benilla-harness-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + for (name, toc, body) in addons { + let dir = tmp.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(format!("{name}.toc")), + format!("## Interface: 11200\n{toc}main.lua\n"), + ) + .unwrap(); + std::fs::write( + dir.join("main.lua"), + format!("Order = (Order or '') .. '{name},'\n{body}"), + ) + .unwrap(); + } + tmp + } + + /// What one probe step answers, `= `. + fn answer(root: &Path, name: &str, chunk: &str) -> String { + let out = probe::probe(root, name, &[probe::Step::Eval(chunk.into())]).unwrap(); + out.answers[0].1.clone() + } + + /// `AddOn_Load` fires `ADDON_LOADED` with the record's folder name (`[rec+0x14]`, `0x51f573`, + /// `0x51f5a2`), whatever case the dependent's `.toc` spelled it in. + #[test] + fn a_dependencys_addon_loaded_carries_its_folder_name() { + let tmp = corpus_folder( + "deps-spelling", + &[ + ( + "Lib", + "", + "Seen = {} Watch = CreateFrame('Frame') Watch:RegisterEvent('ADDON_LOADED') \ + Watch:SetScript('OnEvent', function() table.insert(Seen, arg1) end)", + ), + ("User", "## Dependencies: LIB\n", ""), + ], + ); + assert_eq!( + answer(&tmp, "User", "return table.concat(Seen, ',')"), + "= Lib,User" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// The gate runs before anything loads (`0x51f2fa`): a dependency that requires a missing + /// addon does not load, its files do not run, and its own dependencies are left alone. + #[test] + fn a_dependency_the_gate_refuses_does_not_load() { + let tmp = corpus_folder( + "deps-gate", + &[ + ("Blocked", "## Dependencies: Ghost\n", ""), + ("User", "## Dependencies: Blocked\n", ""), + ], + ); + assert_eq!(answer(&tmp, "User", "return Order"), "= User,"); + assert_eq!( + answer(&tmp, "User", "return IsAddOnLoaded('Blocked')"), + "= nil" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// `AddOn_Load` loads the addons whose `## LoadWith` names the loaded one, right after its + /// `ADDON_LOADED` (`0x51f5b2`). + #[test] + fn a_dependencys_load_with_dependents_load_after_it() { + let tmp = corpus_folder( + "deps-load-with", + &[ + ("Lib", "", ""), + ("Tag", "## LoadWith: Lib\n", ""), + ("User", "## Dependencies: Lib\n", ""), + ], + ); + assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,Tag,User,"); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// The surveyed addon's own `AddOn_Load` ends with the addons that name it in `## LoadWith` + /// (`0x51f5b2`-`0x51f5e1`): they load right after its `ADDON_LOADED`, before the session events. + #[test] + fn the_surveyed_addons_load_with_dependents_load_after_its_addon_loaded() { + let tmp = corpus_folder( + "deps-own-load-with", + &[ + ("Lib", "", ""), + ( + "Tag", + "## LoadWith: User\n", + "TagSawUser = IsAddOnLoaded('User')", + ), + ("User", "## Dependencies: Lib\n", ""), + ], + ); + assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,User,Tag,"); + assert_eq!(answer(&tmp, "User", "return TagSawUser"), "= 1"); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// The surveyed addon is loaded (`0x51f313`) before its dependencies, so one that requires it + /// back finds it loaded and neither runs twice. + #[test] + fn a_dependency_cycle_through_the_surveyed_addon_loads_each_once() { + let tmp = corpus_folder( + "deps-cycle", + &[ + ("Ping", "## Dependencies: Pong\n", ""), + ( + "Pong", + "## Dependencies: Ping\n", + "PingLoadedForPong = IsAddOnLoaded('Ping')\n", + ), + ], + ); + assert_eq!(answer(&tmp, "Ping", "return Order"), "= Pong,Ping,"); + assert_eq!(answer(&tmp, "Ping", "return PingLoadedForPong"), "= 1"); + let _ = std::fs::remove_dir_all(&tmp); + } + /// An indexed frame (`ColorPickerFrame.func`, `ColorPickerFrame:SetColorRGB`) is a missing /// surface, not a missing function. #[test] diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index 6fb9dd62..b93d691c 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -6,7 +6,6 @@ //! after `PLAYER_ENTERING_WORLD` and the ticks, before the render and use probes touch the VM. //! An eval can mutate the VM, so a probe is a debugger; the survey is the measurement. -use std::collections::BTreeSet; use std::path::Path; use benilla_ui::script::UiScript; @@ -71,25 +70,16 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option { script.set_screen_size(1024.0, 768.0); // No saved-variable roots, so a probe never touches a player's saved variables; the AddOns // root is passed as the survey passes it, so `LoadAddOn` resolves. - script.register_addons(registry, Some(root.to_path_buf()), None, None); + script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None); super::seat_a_session(&mut script); let _ = crate::ui_script::load_default_ui(&script); - let mut dep_order: Vec = Vec::new(); - super::load_dependencies( - &mut script, - root, - &toc, - &installed, - &mut BTreeSet::new(), - &mut dep_order, - ); + // Marked before its dependencies load (`0x51f313`), as the survey does. + script.mark_addon_loaded(name); + let _ = super::load_dependencies(&script, root, ®istry, &toc); let load_errors = super::load_addon_files(&script, root, name, &toc).errors; - // The registry must agree with the VM, or `IsAddOnLoaded` answers for another session; the - // dependency chain stamped itself as it loaded. - script.mark_addon_loaded(name); - let session_errors = super::drive_session_start(&mut script, name, &installed); + let session_errors = super::drive_session_start(&mut script, name, &installed, root, ®istry); let answers = steps .iter() diff --git a/crates/benilla-app/src/addon_harness/together.rs b/crates/benilla-app/src/addon_harness/together.rs index bb8112cd..6aaa9312 100644 --- a/crates/benilla-app/src/addon_harness/together.rs +++ b/crates/benilla-app/src/addon_harness/together.rs @@ -16,15 +16,11 @@ //! `LoadLoadOnDemandPlugins`, Auctioneer's stub), so honouring the flag would model a shorter //! session than anyone plays. -use std::collections::BTreeSet; use std::path::Path; use benilla_ui::script::UiScript; -use super::{ - corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET, -}; -use benilla_ui::toc::Toc; +use super::{corpus, HarnessLoad, ADDON_INSTRUCTION_BUDGET}; /// How many fresh-VM runs [`survey_together`] makes: three is the fewest that tell "always" from /// "sometimes". @@ -88,47 +84,33 @@ pub fn survey_together(root: &Path) -> Vec { /// One walk in one VM. fn survey_together_once(root: &Path) -> Vec<(String, Vec)> { - let (names, installed, registry) = corpus(root); + let (names, _, registry) = corpus(root); let Ok(mut script) = UiScript::new() else { return Vec::new(); }; script.set_screen_size(1024.0, 768.0); - script.register_addons(registry, Some(root.to_path_buf()), None, None); + script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None); super::seat_a_session(&mut script); let _ = crate::ui_script::load_default_ui(&script); - // `seen` spans the whole walk: a library declared by many dependents loads once. - let mut seen: BTreeSet = BTreeSet::new(); - // `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256 - // distinct rows and evicts. - let mut load_errors: Vec<(String, Vec)> = Vec::new(); - for name in &names { - let Some(toc) = manifest_path(root, name) - .and_then(|p| std::fs::read(p).ok()) - .map(|b| Toc::parse(&benilla_ui::source::decode(&b))) - else { - continue; - }; - if !seen.insert(name.to_ascii_lowercase()) { - continue; // already loaded as a dependency - } + // The live walk: `AddOn_Load` on every addon in order, through the same driver, so a + // library loads once under whichever dependent reaches it first and an addon that names + // another in `## LoadWith` loads right after it. The corpus is walked whole, LoadOnDemand + // included, and past the version check. + let mut host = HarnessLoad::new(&script, root, ®istry); + for row in 0..registry.len() { // Re-armed per addon, as the live walk does, so one runaway does not starve the rest. script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET); - let mut pulled: Vec = Vec::new(); - load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled); - // A dependency's load failures are collected only here: it loads once, under the first - // dependent to reach it, and never gets a turn of its own. - for dep in pulled { - load_errors.push((dep.name, dep.files.errors)); - } - let files = load_addon_files(&script, root, name, &toc); - load_errors.push((name.clone(), files.errors)); - script.mark_addon_loaded(name); - script.fire_event( - "ADDON_LOADED", - vec![benilla_ui::script::ScriptValue::Str(name.clone())], - ); + script.load_addon_row(&mut host, row, false, false); } + // `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256 + // distinct rows and evicts. A dependency's failures are collected here too: it loads once and + // never gets a turn of its own. + let load_errors: Vec<(String, Vec)> = host + .loaded + .into_iter() + .map(|l| (l.name, l.files.errors)) + .collect(); script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET); for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] { script.fire_event(event, Vec::new()); diff --git a/crates/benilla-app/src/addon_harness/use_probe.rs b/crates/benilla-app/src/addon_harness/use_probe.rs index 92f0a304..82378194 100644 --- a/crates/benilla-app/src/addon_harness/use_probe.rs +++ b/crates/benilla-app/src/addon_harness/use_probe.rs @@ -24,9 +24,9 @@ use super::render::{Painted, RenderBaseline}; /// it, in painter order, are dropped, so a broken ninth widget reads clean. pub const MAX_USE_TARGETS: usize = 8; -/// How far the drag travels: past `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`) so -/// `OnDragStart` fires, and inside a 16 px item slot so the release lands on the same frame. -const DRAG_PX: f32 = 5.0; +/// How far the drag travels: past the drag-start distance (0.01 of the screen diagonal, 12.8 root +/// units at the 1024×768 these runs use) so `OnDragStart` fires. +const DRAG_PX: f32 = 14.0; /// Where the cursor is parked at the end, off-screen so the last `OnLeave` always fires. const PARKED: (f32, f32) = (-10_000.0, -10_000.0); diff --git a/crates/benilla-app/src/addon_harness/use_probe_tests.rs b/crates/benilla-app/src/addon_harness/use_probe_tests.rs index 0b8b557a..59ee8108 100644 --- a/crates/benilla-app/src/addon_harness/use_probe_tests.rs +++ b/crates/benilla-app/src/addon_harness/use_probe_tests.rs @@ -90,7 +90,7 @@ fn the_use_column_can_fail() { "#, ), ); - // `OnDragStart` fires only past the 4 px threshold, so the drag must actually move. + // `OnDragStart` fires only past the drag-start distance, so the drag must actually move. fx.addon( "RaisesOnDrag", &painted_button( diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 9250f3b7..7da05226 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -219,10 +219,7 @@ fn feed_zone_events( subzone: signal.subzone_text.clone(), minimap: minimap_text.clone(), pvp_type: pvp.map(|(ty, _)| ty.to_string()), - pvp_faction: pvp - .map(|(_, faction)| faction) - .filter(|f| !f.is_empty()) - .map(str::to_string), + pvp_faction: pvp_faction_text(pvp), is_arena, }); @@ -245,6 +242,14 @@ fn feed_zone_events( cache.wmo_group = wmo_group; } +/// `GetZonePVPInfo`'s second value: with a type answered, the faction name is always a string, +/// starting as the empty string (`0x48d633`, `0x882748`) and pushed by `lua_pushstring` +/// (`0x48d68f`) whether or not a FactionGroup row supplied one; only the bail (`0x48d6b5`) pushes +/// nil. +fn pvp_faction_text(pvp: Option<(&str, &str)>) -> Option { + pvp.map(|(_, faction)| faction.to_string()) +} + /// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read /// before any bail. The type is nil with no zone row, no faction template, or the display gate /// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six @@ -433,6 +438,21 @@ mod tests { assert_eq!(pvp(true, &orgrimmar), Some("hostile")); } + /// An answered type always carries a faction string, `""` in an ownerless zone such as + /// Stranglethorn Vale (`0x48d633`); no type, no string (`0x48d6b5`). + #[test] + fn an_answered_type_carries_an_empty_faction_name_not_nil() { + assert_eq!( + pvp_faction_text(Some(("contested", ""))), + Some(String::new()) + ); + assert_eq!( + pvp_faction_text(Some(("friendly", "Alliance"))), + Some("Alliance".to_string()) + ); + assert_eq!(pvp_faction_text(None), None); + } + /// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it. #[test] fn is_arena_survives_a_closed_gate() { diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index a3b035ba..062dd916 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -29,7 +29,7 @@ use bevy::input::ButtonState; use bevy::prelude::*; use benilla_ui::script::keybind::KeybindRequest; -use benilla_ui::script::UiScript; +use benilla_ui::script::{KeyUp, KeyWalk, UiScript}; use crate::char_select::InWorldGated; use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture}; @@ -93,7 +93,46 @@ pub(crate) struct BindingsState { /// Normalized like [`latched`](Self::latched) and reconciled against `ButtonInput` every /// pass, so a window deactivate empties it and a key held across an alt-tab resumes, as in /// the reference. Keyboard only: mouse edges cannot repeat. - down: Vec, + down: KeyDownList, +} + +/// The keys believed down, the reference's own list (`0x4248b3`): a press of a key on it is an +/// auto-repeat, whatever the OS flagged. One instance is the bindings', another the UI key feed's +/// ([`crate::ui_script`]), which decides repeat for the frames by the same rule and reconcile. +#[derive(Default)] +pub(crate) struct KeyDownList(Vec); + +impl KeyDownList { + /// Drop what is no longer physically down, per bevy's button plane: a window deactivate + /// empties it (the reference's `0x424790` wipe), so the first repeat after an alt-tab is a + /// fresh press and re-latches. + pub(crate) fn reconcile(&mut self, keys: &ButtonInput) { + self.0.retain(|&kc| key_physically_down(kc, keys)); + } + + /// Record a press; whether the key was already down, which makes it a repeat. + pub(crate) fn press(&mut self, key: KeyCode) -> bool { + let key = chord::normalize_key(key); + let repeat = self.0.contains(&key); + if !repeat { + self.0.push(key); + } + repeat + } + + pub(crate) fn release(&mut self, key: KeyCode) { + let key = chord::normalize_key(key); + self.0.retain(|&kc| kc != key); + } +} + +/// `ENTER` is down while either physical key is, since [`chord::normalize_key`] folds +/// `NUMPADENTER` into it. +fn key_physically_down(kc: KeyCode, keys: &ButtonInput) -> bool { + match kc { + KeyCode::Enter => keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter), + kc => keys.pressed(kc), + } } /// The physical input a latch waits on: a keyboard key by its code, not its name, so its release @@ -392,9 +431,7 @@ fn latch_and_dispatch( // ── The pressed-key reconcile ── against bevy's button planes, before this frame's messages: // a release the window never saw drops off the list, and a window deactivate empties it, the // reference's `0x424790` wipe, so a key held across an alt-tab re-latches on its next repeat. - state - .down - .retain(|&kc| physically_down(Held::Key(kc), &keys, &buttons)); + state.down.reconcile(&keys); // ── Keyboard ── press edges run their command, gated on ownership and the capture arm; // release edges unlatch and run the `runOnUp` up-half. @@ -413,15 +450,17 @@ fn latch_and_dispatch( | KeyCode::ArrowUp | KeyCode::ArrowDown ); + // The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the + // walk goes on and the world frame's binding runs while typing. + let arrow_exempt = arrow_exempt + || chord::key_token(ev.key_code, &layout) + == Some(chord::KeyName::Word("PRINTSCREEN")); // A keyboard frame ate this one key (the world map's `OnKeyDown`, a cinematic); // per key, so every other binding is untouched. let eaten = capture.consumed.contains(&ev.key_code); // A repeat is a key we already believe is down, the reference's test. Recorded // before the gates below, so a key held through a focused box stays down. - let repeat = state.down.contains(&key); - if !repeat { - state.down.push(key); - } + let repeat = state.down.press(key); // `sup`: the Super deviation (module doc). if (typing && !arrow_exempt) || eaten || sup || repeat { continue; @@ -441,12 +480,44 @@ fn latch_and_dispatch( key: BindKey::Key(name), }; if let Some(command) = dispatch.resolve(chord, dev_plane) { + // The world frame's key-down ran it and returned that (`0x483b70`), so its + // key-up and repeats are the world frame's, not a stale earlier consumer's. + if let Some(s) = script.as_mut() { + s.note_world_key(&name.to_string()); + } press(&mut state, &mut script, command, Held::Key(key)); } } ButtonState::Released => { - state.down.retain(|&kc| kc != key); + state.down.release(key); release(&mut state, &mut script, Held::Key(key)); + // The release, to the frame that took the press (`0x765fd0`), here and not in the + // key feed so it follows this key's press in event order: a binding's press has + // just made the world frame its consumer. PRINTSCREEN arrives as an up alone on + // Windows (`VK_SNAPSHOT` sends no `WM_KEYDOWN`), and with no consumer on record the + // client runs the key-down walk on it (`0x76603c`-`0x76604d`), the world frame's + // binding last: the up then runs the chord as a press, with no latch since no + // release follows. + let Some(name) = chord::walk_name(ev.key_code, &layout) else { + continue; + }; + let Some(s) = script.as_mut() else { + continue; + }; + if s.key_up(&name) == KeyUp::Walked(KeyWalk::Declined) && !sup { + if let Some(token) = chord::key_token(ev.key_code, &layout) { + let chord = Chord { + alt, + ctrl, + shift, + key: BindKey::Key(token), + }; + if let Some(command) = dispatch.resolve(chord, dev_plane) { + press(&mut state, &mut script, command, Held::Key(key)); + state.latched.retain(|(k, _)| *k != Held::Key(key)); + } + } + } } } } @@ -539,10 +610,7 @@ fn physically_down( buttons: &ButtonInput, ) -> bool { match key { - Held::Key(KeyCode::Enter) => { - keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter) - } - Held::Key(kc) => keys.pressed(kc), + Held::Key(kc) => key_physically_down(kc, keys), Held::Mouse(b) => buttons.pressed(b), // A notch is a press and a release in one frame; it is never "held". Held::Wheel => false, @@ -733,7 +801,7 @@ mod tests { } fn with_addon(bindings: &str) -> UiScript { - let mut script = core_script(); + let script = core_script(); script.register_addon_bindings( "ProbeAddon", &benilla_ui::bindings_xml::parse(bindings).expect("well-formed"), @@ -839,6 +907,67 @@ mod tests { ); } + /// The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the walk goes on and + /// the binding runs while typing, with every other key still eaten. + #[test] + fn printscreen_runs_its_binding_while_a_box_has_focus() { + let script = with_addon(PROBE_BINDINGS); + script + .run(r#"SetBinding("PRINTSCREEN", "PROBEEDGE"); SetBinding("G", "PROBEHOLD")"#) + .expect("bind"); + let mut app = vm_harness(script); + app.world_mut().resource_mut::().typing = true; + + press_key(&mut app, KeyCode::KeyG); + app.update(); + assert_eq!(lua_count(&app, "PROBE_DOWN"), 0, "G is eaten by the box"); + + press_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "PRINTSCREEN is declined, so its binding runs" + ); + } + + /// A press that resolved to a binding is the world frame's (`0x483b70`): the key's up goes to + /// it, not to the hidden frame that took the key's last press. + #[test] + fn a_press_that_runs_a_binding_makes_the_world_frame_its_consumer() { + let script = with_addon(PROBE_BINDINGS); + script + .run( + r#" + SetBinding("G", "PROBEEDGE") + CreateFrame("WorldFrame", "WorldFrame") + ups = 0 + stale = CreateFrame("Frame", "StaleG") + stale:EnableKeyboard(true) + stale:SetScript("OnKeyDown", function() end) + stale:SetScript("OnKeyUp", function() ups = ups + 1 end) + "#, + ) + .expect("bind"); + let mut app = vm_harness(script); + { + let mut s = app.world_mut().non_send_resource_mut::(); + assert!(s.key_input("G"), "the frame takes G first"); + s.run("stale:Hide()").unwrap(); + } + press_key(&mut app, KeyCode::KeyG); + app.update(); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "the binding took the press" + ); + let mut s = app.world_mut().non_send_resource_mut::(); + s.run("stale:Show()").unwrap(); + assert!(s.key_up("G").taken()); + assert_eq!(s.eval::("return ups").unwrap(), 0); + } + /// A box taking focus releases nothing already held: the reference's focused box freezes the /// direction bits rather than clearing them (`0x514490` is the OS window-deactivate clear). #[test] @@ -1002,6 +1131,165 @@ mod tests { ); } + /// The UI key feed beside the dispatch, as the world runs them: the feed first, on the same + /// events, with the world frame in the walk. + fn feed_and_dispatch_harness() -> App { + fn feed( + mut script: NonSendMut, + mut capture: ResMut, + mut events: MessageReader, + keys: Res>, + layout: Res, + mut clipboard: NonSendMut, + mut down: Local, + ) { + capture.consumed.clear(); + crate::ui_script::feed_keys( + &mut script, + &mut capture, + events.read(), + &keys, + &mut down, + false, + crate::textinput::Mods { + shift: false, + ctrl: false, + alt: false, + sup: false, + }, + &layout, + &mut clipboard, + None, + ); + } + let script = core_script(); + script + .run(r#"CreateFrame("WorldFrame", "WorldFrame")"#) + .expect("world frame"); + let mut app = vm_harness(script); + app.insert_non_send_resource(crate::textinput::HostClipboard::holding("")) + .add_systems(Update, feed.before(latch_and_dispatch)); + app + } + + /// A key held across an alt-tab resumes at its next OS repeat even when its binding made the + /// world frame its consumer: the feed decides repeat from the same wiped pressed-key list + /// (`0x424790`), not from the OS flag, so the repeat is a fresh press for both and W runs + /// again (`0x4248b3`). + #[test] + fn a_held_movement_key_resumes_after_an_alt_tab_through_the_feed_too() { + let mut app = feed_and_dispatch_harness(); + press_key(&mut app, KeyCode::KeyW); + app.update(); + assert!(state(&app).pressed(Input::MoveForward)); + + app.world_mut() + .resource_mut::>() + .release_all(); + app.update(); + assert!( + !state(&app).pressed(Input::MoveForward), + "the deactivate releases the direction bits" + ); + + repeat_key(&mut app, KeyCode::KeyW); + app.update(); + assert!( + state(&app).pressed(Input::MoveForward), + "the first repeat after re-activation re-latches" + ); + assert!( + !app.world() + .resource::() + .consumed + .contains(&KeyCode::KeyW), + "…the feed did not hand the repeat to the world frame's stub" + ); + + // A real repeat of the key now held is the stub's, and eaten by nobody's binding. + repeat_key(&mut app, KeyCode::KeyW); + app.update(); + assert!(state(&app).pressed(Input::MoveForward)); + } + + /// A press and its release in one frame's batch reach the dispatch in event order: the release + /// follows the press that made the world frame the consumer, so it is not sent on to the frame + /// that took the key's last press. + #[test] + fn a_press_and_a_release_in_one_batch_go_to_the_world_frame_not_a_stale_consumer() { + let mut app = feed_and_dispatch_harness(); + { + let mut s = app.world_mut().non_send_resource_mut::(); + s.run( + r#" + ups = 0 + stale = CreateFrame("Frame", "StaleBatch") + stale:EnableKeyboard(true) + stale:SetScript("OnKeyDown", function() end) + stale:SetScript("OnKeyUp", function() ups = ups + 1 end) + "#, + ) + .unwrap(); + assert!(s.key_input("W"), "the frame takes W once"); + s.run("stale:EnableKeyboard(false)").unwrap(); + } + press_key(&mut app, KeyCode::KeyW); + release_key(&mut app, KeyCode::KeyW); + app.update(); + let s = app.world().non_send_resource::(); + assert_eq!(s.eval::("return ups").unwrap(), 0); + } + + /// PRINTSCREEN reaches Windows as an up alone (`VK_SNAPSHOT` sends no `WM_KEYDOWN`). With no + /// consumer on record the client runs the key-down walk on the up (`0x76603c`-`0x76604d`), the + /// world frame's binding last, so the binding runs on the release; a frame that takes the walk + /// keeps it from the binding; and a platform that sends both halves runs it once. + #[test] + fn printscreen_as_an_up_alone_runs_its_binding_through_the_key_down_walk() { + let script = with_addon(PROBE_BINDINGS); + script + .run( + r#"SetBinding("PRINTSCREEN", "PROBEEDGE") CreateFrame("WorldFrame", "WorldFrame")"#, + ) + .expect("bind"); + let mut app = vm_harness(script); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "PROBE_EDGE"), 1, "the up alone runs it"); + + // A frame that takes the key-down walk keeps it from the binding. + app.world_mut() + .non_send_resource_mut::() + .run( + r#" + got = 0 + cover = CreateFrame("Frame", "PrintCover") + cover:EnableKeyboard(true) + cover:SetScript("OnKeyDown", function() got = got + 1 end) + "#, + ) + .unwrap(); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "got"), 1, "the frame took the walk"); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "…so the binding did not run" + ); + + // A platform that sends both halves: the press ran it and made the world frame the + // consumer, so the up is delivered, not walked again. + app.world_mut() + .non_send_resource_mut::() + .run("cover:Hide()") + .unwrap(); + press_key(&mut app, KeyCode::PrintScreen); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "PROBE_EDGE"), 2, "…once, not twice"); + } + /// An addon's bindings that call the movement functions: a Start and a Stop alone. const MOVE_BINDINGS: &str = r#" MoveForwardStart() diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs index 2e7f4ffe..8f5e2124 100644 --- a/crates/benilla-app/src/bindings/chord.rs +++ b/crates/benilla-app/src/bindings/chord.rs @@ -113,6 +113,32 @@ pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option { } } +/// The name the key-down walk's `arg1` carries for a press (`0x76b7d0`), `None` for a key the +/// client drops before any walk: the modifiers go by their own names (`0`, `1`, `2` of the table, +/// `SHIFT`, `CTRL`, `ALT` at `0x846e80`, `0x846e88`, `0x846e90`), and `UNKNOWN` is the name of the +/// codes the table cannot name, `0x210` and `0x211` (ScrollLock and Pause, and F14 and F15 on a +/// Mac). Every other key without a name never reaches the walk: the Windows key table drops it +/// where `MapVirtualKeyA` answers 0 (`0x42da39`: the Windows and Menu keys, media keys, a key the +/// layout drops), the Mac table `0x5bf320` has -1 for Command and fn. Unlike [`key_token`], which +/// is a binding's, a modifier still gets a down and an up. +pub(crate) fn walk_name(k: KeyCode, layout: &LayoutNames) -> Option { + if let Some(name) = key_token(k, layout) { + return Some(name.to_string()); + } + Some( + match k { + KeyCode::ShiftLeft | KeyCode::ShiftRight => "SHIFT", + KeyCode::ControlLeft | KeyCode::ControlRight => "CTRL", + KeyCode::AltLeft | KeyCode::AltRight => "ALT", + KeyCode::ScrollLock | KeyCode::Pause => "UNKNOWN", + #[cfg(target_os = "macos")] + KeyCode::F14 | KeyCode::F15 => "UNKNOWN", + _ => return None, + } + .to_string(), + ) +} + /// The fixed half of `0x42d800` and the Mac table: every key no layout names /// ([`super::layout::position`]). fn fixed_name(k: KeyCode) -> Option { diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 6f1b0ff7..a06946a3 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -948,6 +948,8 @@ pub(super) fn seed_ui_fixture( "mouseover", Some(benilla_ui::script::UnitState { exists: true, + // A unit is found by its guid; the builder hides for guid 0 (`0x52a04e`). + guid: 0xF130_0000_0044_0001, name: Some("Stormwind Guard".into()), health: 38, max_health: 50, @@ -975,6 +977,7 @@ pub(super) fn seed_ui_fixture( "mouseover", Some(benilla_ui::script::UnitState { exists: true, + guid: 0x0000_0000_0000_0B0B, is_player: true, name: Some("Bob".into()), level: 60, @@ -1708,6 +1711,7 @@ fn seed_cooldown_filmstrip( texture: texture.clone(), count: 1, quality: Some(1), + inventory_type: 0, item_id: 6948, link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()), locked: false, @@ -1760,6 +1764,7 @@ fn seed_bag_window( texture: icon(disp), count, quality: Some(quality), + inventory_type: 0, item_id: id, link: Some(format!("|cffffffff|Hitem:{id}|h[{name}]|h|r")), locked: false, @@ -1846,6 +1851,7 @@ fn seed_equipped_bags( texture: icon(disp), count, quality: Some(quality), + inventory_type: 0, item_id: 0, link: Some(format!("|cffffffff|Hitem:0|h[{name}]|h|r")), locked: false, diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 81102ffe..36d2e23b 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -113,9 +113,34 @@ pub(crate) use probes::{ fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin, JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, - ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin, - SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin, + ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, + RevealAuditPlugin, SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin, }; +/// A probe that reads the native screens' rosters (the park's own, and the one re-sent after a +/// create or delete) cannot run under `WOW_GLUE_STOCK=1`, where the screen asks for its own and the +/// probe would sit until its timeout. Refuse it where it is requested: an error at plugin build, +/// and the exit of a driverless run ([`crate::run_mode::fatal_when_driverless`]), at the first +/// frame. `true` means the probe must not install. +pub(crate) fn refuse_under_stock(app: &mut App, probe: &'static str) -> bool { + refuse_if_stock(app, probe, crate::ui_script::glue::GlueStock::from_env().0) +} + +fn refuse_if_stock(app: &mut App, probe: &'static str, stock: bool) -> bool { + if !stock { + return false; + } + error!( + "probe: {probe} reads the native screens' rosters and cannot run under WOW_GLUE_STOCK=1; \ + unset one of them" + ); + if crate::run_mode::fatal_when_driverless(&format!("{probe} under WOW_GLUE_STOCK=1")) { + app.add_systems(Startup, |mut exit: MessageWriter| { + exit.write(AppExit::error()); + }); + } + true +} + pub(crate) use scenarios::ui_opted_in; use scenarios::GlueScreen; use scenarios::{ @@ -1083,3 +1108,16 @@ fn drive_capture( } } } + +#[cfg(test)] +mod refuse_tests { + use super::*; + + /// A native-only probe installs under the native screens and is refused under the stock ones. + #[test] + fn a_native_only_probe_is_refused_under_the_stock_screens() { + let mut app = App::new(); + assert!(!refuse_if_stock(&mut app, "WOW_RIG", false)); + assert!(refuse_if_stock(&mut app, "WOW_RIG", true)); + } +} diff --git a/crates/benilla-app/src/capture/probe_charcreate.rs b/crates/benilla-app/src/capture/probe_charcreate.rs index 3709570d..090ad559 100644 --- a/crates/benilla-app/src/capture/probe_charcreate.rs +++ b/crates/benilla-app/src/capture/probe_charcreate.rs @@ -21,6 +21,9 @@ impl Plugin for ProbeCharCreatePlugin { else { return; // inert without a valid env spec }; + if super::refuse_under_stock(app, "WOW_PROBE_CHARCREATE") { + return; + } let keep = std::env::var("WOW_PROBE_CHARCREATE_KEEP").as_deref() == Ok("1"); app.insert_resource(CharCreateProbe { req, diff --git a/crates/benilla-app/src/capture/probe_env.rs b/crates/benilla-app/src/capture/probe_env.rs index e0f6adb8..36b7558c 100644 --- a/crates/benilla-app/src/capture/probe_env.rs +++ b/crates/benilla-app/src/capture/probe_env.rs @@ -72,6 +72,26 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[ // First fire at `_AT` seconds, re-armed `_AGAIN` seconds into every later world entry. wall_clock: true, }, + ProbeVar { + name: "WOW_PROBE_GLUE_LUA", + purpose: "\"\" — under WOW_GLUE_STOCK=1, run a Lua chunk once in the stock glue VM (press the login screen's buttons headlessly)", + wall_clock: true, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_LUA_AT", + purpose: " — when the glue chunk runs (default 10)", + wall_clock: false, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_SHOT", + purpose: " — one PNG after the glue chunk runs", + wall_clock: false, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_SHOT_AFTER", + purpose: " — how long after the glue chunk the shot is taken (default 1)", + wall_clock: false, + }, ProbeVar { name: "WOW_PROBE_LUA_AT", purpose: " — when the chunk runs after the first world entry (default 10)", diff --git a/crates/benilla-app/src/capture/probe_rig.rs b/crates/benilla-app/src/capture/probe_rig.rs index c349aa61..119623b7 100644 --- a/crates/benilla-app/src/capture/probe_rig.rs +++ b/crates/benilla-app/src/capture/probe_rig.rs @@ -73,6 +73,9 @@ impl Plugin for ProbeRigPlugin { else { return; // inert without a parseable spec (parse() has already said why) }; + if super::refuse_under_stock(app, "WOW_RIG") { + return; + } info!("rig: {}", spec.describe()); // Publish the claim on the character pick, so the roster knows the pick is spoken for. if let Some(name) = rig_char_name(&spec) { diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index ddc4ea14..8b9367d5 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -365,6 +365,97 @@ fn fire_probe_lua( } } +/// `WOW_PROBE_GLUE_LUA=""`: under `WOW_GLUE_STOCK=1`, runs the chunk once in the stock +/// glue VM `WOW_PROBE_GLUE_LUA_AT` seconds (default 10) into the run, to press the login screen's +/// buttons headlessly; `WOW_PROBE_GLUE_SHOT=` then saves one PNG +/// `WOW_PROBE_GLUE_SHOT_AFTER` seconds (default 1) later. The chunk is never logged: it may carry +/// a typed password. +pub(crate) struct ProbeGlueLuaPlugin; + +impl Plugin for ProbeGlueLuaPlugin { + fn build(&self, app: &mut App) { + let secs = |k: &str, d: f32| { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d) + }; + app.insert_resource(ProbeGlueLua { + chunk: std::env::var("WOW_PROBE_GLUE_LUA").unwrap_or_default(), + at: secs("WOW_PROBE_GLUE_LUA_AT", 10.0), + shot: std::env::var("WOW_PROBE_GLUE_SHOT").ok(), + shot_after: secs("WOW_PROBE_GLUE_SHOT_AFTER", 1.0), + ran_at: None, + shot_done: false, + }) + .add_systems(Update, fire_probe_glue_lua.after(crate::ui_script::UiInput)); + } +} + +#[derive(Resource)] +struct ProbeGlueLua { + chunk: String, + at: f32, + shot: Option, + shot_after: f32, + ran_at: Option, + shot_done: bool, +} + +/// The log line for a failed glue chunk: its kind alone. The error's text can quote the chunk (a +/// syntax error's `near ''`, a chunk named by its own source), and the chunk may carry a +/// typed password. +fn glue_chunk_failure(e: &dyn std::fmt::Display) -> String { + let text = e.to_string(); + let kind = if text.starts_with("syntax error") { + "a syntax error" + } else if text.starts_with("runtime error") { + "a runtime error" + } else if text.starts_with("callback error") { + "an error in a callback" + } else { + "an error" + }; + format!("probe-glue-lua: the chunk failed with {kind}") +} + +/// Runs the chunk once the glue VM stands and its time has come, then the one shot. +fn fire_probe_glue_lua( + mut probe: ResMut, + time: ProbeClock, + script: Option>, + mut commands: Commands, +) { + let now = time.elapsed_secs(); + match probe.ran_at { + None => { + let Some(script) = script.filter(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue) + else { + return; + }; + if now < probe.at { + return; + } + probe.ran_at = Some(now); + info!("probe-glue-lua: running the chunk at {now:.1}s"); + if let Err(e) = script.run(&probe.chunk) { + error!("{}", glue_chunk_failure(&e)); + } + } + Some(ran) if !probe.shot_done && now >= ran + probe.shot_after => { + probe.shot_done = true; + if let Some(out) = probe.shot.clone() { + use bevy::render::view::screenshot::{save_to_disk, Screenshot}; + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(out.clone())); + info!("probe-glue-lua: shot writing {out}"); + } + } + _ => {} + } +} + /// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre /// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s), /// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit. @@ -725,3 +816,28 @@ mod tests { ); } } + +#[cfg(test)] +mod glue_chunk_tests { + /// A failing chunk is logged by its kind, never its text: a syntax error quotes its token. + #[test] + fn a_failed_glue_chunk_never_echoes_its_text() { + let s = benilla_ui::script::UiScript::new_glue().unwrap(); + for (chunk, kind) in [ + ("x = 1 'SECRET'", "a syntax error"), + ("error('SECRET')", "a runtime error"), + ("SECRET()", "a runtime error"), + ] { + let e = s.run(chunk).unwrap_err(); + assert!( + e.to_string().contains("SECRET"), + "the raw error quotes the chunk: {e}" + ); + let line = super::glue_chunk_failure(&e); + assert_eq!( + line, + format!("probe-glue-lua: the chunk failed with {kind}") + ); + } + } +} diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 6a8cfb21..1582b464 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -9,7 +9,8 @@ mod fx_draw_census; pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin; mod act; pub(crate) use act::{ - ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, + ProbeChatPlugin, ProbeDragPlugin, ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, + ProbeLuaPlugin, }; /// The probe run's shell: its bounded lifetime and its window's level, parking and size. diff --git a/crates/benilla-app/src/capture/realm_list.rs b/crates/benilla-app/src/capture/realm_list.rs index 270314f4..d2c39341 100644 --- a/crates/benilla-app/src/capture/realm_list.rs +++ b/crates/benilla-app/src/capture/realm_list.rs @@ -47,7 +47,10 @@ pub(super) fn seed_realm_list( } match *step { 0 => { - lists.write(crate::net::RealmListMessage { realms: fixture() }); + lists.write(crate::net::RealmListMessage { + realms: fixture(), + failed: false, + }); } 2 => crate::realm_select::open_over_char_select(&mut realms, &choice), 3.. => return, diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index d815c825..375020bd 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -20,7 +20,7 @@ use crate::char_select::{ClientState, Roster}; use crate::entities::CharCreate; use crate::glue_strings::GlueStrings; use crate::net::{CharActionResultMessage, CharPick, CharRequest}; -use crate::portrait::{CreateLook, GlueLook, GluePreview}; +use crate::portrait::{CreateLook, GluePreview}; use crate::sound::GlueSound; /// Alliance and Horde race columns, top to bottom: `CharacterCreateEnumerateRaces` fills buttons @@ -71,11 +71,11 @@ impl Plugin for CharCreatePlugin { Update, ( screen::rescale_screen, - create_input, + create_input.run_if(crate::ui_script::glue::native_glue), debug_auto_create, debug_pick, debug_shot, - rotate_model, + rotate_model.run_if(crate::ui_script::glue::native_glue), refresh::refresh_dynamic, refresh_name_box, refresh::refresh_hover, @@ -86,6 +86,8 @@ impl Plugin for CharCreatePlugin { ) .chain() .before(crate::glue::GlueVisuals) + // The create screen writes the glue booth's preview. + .in_set(crate::portrait::GluePreviewFeed) .run_if(in_state(ClientState::CharCreate)) .after(benilla_world::schedule::WorldStage::Net), ); @@ -188,8 +190,7 @@ fn debug_pick( sel.dials[i] = want.min(n - 1); } } - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); info!( "char create: pick instrument set race {} sex {} class {} dials {:?}", sel.race, sel.sex, sel.class, sel.dials @@ -468,8 +469,7 @@ fn create_input( let _ = pick.0.send(CharRequest::Create(sel.request())); } if changed_look { - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); } } diff --git a/crates/benilla-app/src/char_create/screen.rs b/crates/benilla-app/src/char_create/screen.rs index 30745ca1..e8e5f6ae 100644 --- a/crates/benilla-app/src/char_create/screen.rs +++ b/crates/benilla-app/src/char_create/screen.rs @@ -9,7 +9,7 @@ use bevy::window::PrimaryWindow; use crate::char_select::{race_name, wow_font}; use crate::entities::CharCreate; use crate::glue_strings::GlueStrings; -use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT}; +use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT}; use benilla_assets::WorldAssets; use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine}; @@ -41,8 +41,7 @@ pub(super) fn enter_create( window: Query<&Window, With>, ) { sel.reset(catalog.as_deref()); - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); preview.yaw = INITIAL_FACING; if let Some(mut wa) = world_assets { art.ensure_loaded(&mut wa, &mut images, &mut add_mats); @@ -110,6 +109,7 @@ fn spawn_screen( let root = commands .spawn(( + crate::glue::NativeGlue, CharCreateUi { s }, DynTint::Backdrop, GlobalZIndex(SCREEN_Z), diff --git a/crates/benilla-app/src/char_select/addons.rs b/crates/benilla-app/src/char_select/addons.rs index 9c5c0bd9..342de262 100644 --- a/crates/benilla-app/src/char_select/addons.rs +++ b/crates/benilla-app/src/char_select/addons.rs @@ -720,6 +720,7 @@ fn spawn_panel( commands .spawn(( AddonsUi, + crate::glue::NativeGlue, GlobalZIndex(1200), // over the select screen's 1100, like the delete dialog // The reference's full-screen BACKGROUND layer, black at 0.75; it also keeps a stray // click off the screen behind. diff --git a/crates/benilla-app/src/char_select/dialog.rs b/crates/benilla-app/src/char_select/dialog.rs index ce4d150c..75051e9c 100644 --- a/crates/benilla-app/src/char_select/dialog.rs +++ b/crates/benilla-app/src/char_select/dialog.rs @@ -223,6 +223,7 @@ fn spawn_dialog( commands .spawn(( DialogUi, + crate::glue::NativeGlue, GlobalZIndex(1200), // over the select screen's 1100 Node { width: Val::Percent(100.0), diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs index c58f3008..a09b3c0c 100644 --- a/crates/benilla-app/src/char_select/input.rs +++ b/crates/benilla-app/src/char_select/input.rs @@ -195,9 +195,10 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .insert_resource(CharPick(pick_tx)) - .insert_resource(RealmChoice(realm_tx)) + .insert_resource(CharPick(pick_tx.into())) + .insert_resource(RealmChoice(realm_tx.into())) .add_message::() + .add_message::() .add_message::() .add_systems(PreUpdate, take_input); if list_first { @@ -226,11 +227,21 @@ mod tests { } else { "screen first" }; + assert!( + !app.world().resource::().shown, + "{order}: the list's Cancel ran" + ); assert!( realm_rx .try_iter() - .any(|r| matches!(r, RealmRequest::Abandon)), - "{order}: the list's Cancel ran" + .all(|r| matches!(r, RealmRequest::Refresh)), + "{order}: and over character select it closes nothing" + ); + assert!( + app.world() + .resource::>() + .is_empty(), + "{order}: nor ends the login" ); assert_eq!( *app.world().resource::>().get(), diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index dc3678ff..f2d1bda5 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -73,48 +73,51 @@ impl Plugin for CharSelectPlugin { ) .init_resource::() .init_resource::() + .init_resource::() .add_systems( OnEnter(ClientState::CharSelect), - (screen::enter_select, stamp_select_entry), + ( + screen::enter_select.run_if(crate::ui_script::glue::native_glue), + stamp_select_entry, + ), ) .add_systems( OnExit(ClientState::CharSelect), - (screen::exit_select, clear_select_entry), + ( + screen::exit_select, + screen::clear_select_scene.run_if(crate::ui_script::glue::native_glue), + clear_select_entry, + ), + ) + .add_systems( + OnEnter(ClientState::InWorld), + refresh::reset_select_background, ) .add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke)) .add_systems( Update, ( - // Runs in every state: the reconnect auto-answer and the logout edge arrive - // `InWorld`. `back_on_disconnect` runs after the entry: a session that died - // during entry queues both edges into one frame, and the dead one must win. - ( - apply_roster_policy, - enter_on_connected, - back_on_logout, - back_on_login_refused, - back_on_disconnect, - // Last and ungated: it mirrors the pick the frame ended with, and world - // entry is reached from the create screen too. - persist_last_character, - ) - .chain(), + session_edges(), ( screen::materialize_screen, - input::select_input, - input::rotate_model, + input::select_input.run_if(crate::ui_script::glue::native_glue), + input::rotate_model.run_if(crate::ui_script::glue::native_glue), debug_select_dialog, - dialog::drive_delete_dialog, + dialog::drive_delete_dialog.run_if(crate::ui_script::glue::native_glue), // Ahead of the dialog driver, so a refused delete shows the frame it lands. delete_result, // The one `GlueDialog`: here, the refused login and the refused delete. - crate::glue::dialog::drive_glue_dialog, + crate::glue::dialog::drive_glue_dialog + .run_if(crate::ui_script::glue::native_glue), debug_select_addons, debug_select_walk, - addons::drive_addons_panel, + addons::drive_addons_panel.run_if(crate::ui_script::glue::native_glue), refresh::refresh_list, refresh::refresh_banner_and_buttons, - refresh::feed_glue_preview, + // The stock screens drive the booth off the glue VM instead. + refresh::feed_glue_preview + .run_if(crate::ui_script::glue::native_glue) + .in_set(crate::portrait::GluePreviewFeed), debug_select_shot, ) .chain() @@ -137,6 +140,26 @@ impl Plugin for CharSelectPlugin { } } +/// The session's edges, in every state: the reconnect auto-answer and the logout edge arrive +/// `InWorld`. A logout or a refused login clears the pick before the roster policy reads it: the +/// roster comes right behind them on the kept link, often in the same drain, and must be shown, +/// not answered with the pick just left. `back_on_disconnect` runs after the entry: a session that +/// died during entry queues both edges into one frame, and the dead one must win. +fn session_edges() -> bevy::ecs::schedule::ScheduleConfigs { + ( + back_on_logout, + back_on_login_refused, + apply_roster_policy, + enter_on_connected, + back_on_disconnect, + // Last and ungated: it mirrors the pick the frame ended with, and world entry is reached + // from the create screen too. + persist_last_character, + ) + .chain() + .into_configs() +} + // ── The roster and pick policy ────────────────────────────────────────────────────────────────── /// The account roster and the pick policy's memory. @@ -441,7 +464,7 @@ fn back_on_disconnect( /// `SMSG_CHARACTER_LOGIN_FAILED`: back to select with the refusal in the glue dialog. The IO /// thread announces the entry with the pick, so this undoes a half-built one. The pick must be -/// cleared, or the relist behind the refusal is auto-answered and the refused login loops. +/// cleared, or the roster behind the refusal is auto-answered and the refused login loops. fn back_on_login_refused( mut msgs: MessageReader, mut roster: ResMut, @@ -588,7 +611,8 @@ fn debug_glue_roundtrip( } /// The logout smoke (`WOW_LOGOUT_SMOKE=1`, with `WOW_CHAR`): once in the world, `/logout`, return -/// to CharSelect, enter the world again and close the window. The second entry streams a map +/// to CharSelect, wait for the roster the logout brings on the link the world was entered by, +/// enter the world again and close the window. The second entry streams a map /// `terrain_stream::release_world` tore down. It ends by a window close, not an `AppExit`, since /// only the close reaches the shutdown tail that saves variables and addon files. fn debug_logout_smoke( @@ -602,12 +626,35 @@ fn debug_logout_smoke( mut exit: MessageWriter, mut close: MessageWriter, windows: Query>, + mut lists: MessageReader, + // The world session's number at the entry, and whether a roster has come since the logout. + mut link: Local>, + mut relisted: Local, mut phase: Local, mut mark: Local, ) { if std::env::var("WOW_LOGOUT_SMOKE").is_err() { return; } + for list in lists.read() { + if *phase < 2 { + *link = Some(list.epoch); + } else if !*relisted { + *relisted = true; + if Some(list.epoch) == *link { + info!( + "logout-smoke: roster after the logout on the same link — {} char(s)", + list.characters.len() + ); + } else { + error!( + "logout-smoke: FAILED — the roster after the logout came on world session {} \ + where the world was entered on {:?}", + list.epoch, *link + ); + } + } + } let now = time.elapsed_secs(); match *phase { 0 if *state.get() == ClientState::InWorld && player.active => { @@ -625,21 +672,24 @@ fn debug_logout_smoke( } // Residency is read here, not on arrival: `release_world` runs about 150 ms after the // state edge, on the world-live falling edge. - 3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) { - Some((guid, name)) => { - info!( - "logout-smoke: {} tiles resident after the release (must be 0)", - streamer.residency().1 - ); - info!("logout-smoke: re-entering the world as {name}"); - send_pick(&mut roster, &pick, guid); - (*phase, *mark) = (4, now); + // The roster the logout brings, not the one that survived it, decides the re-entry. + 3 if now - *mark > 4.0 && *relisted => { + match roster.chars.first().map(|c| (c.guid, c.name.clone())) { + Some((guid, name)) => { + info!( + "logout-smoke: {} tiles resident after the release (must be 0)", + streamer.residency().1 + ); + info!("logout-smoke: re-entering the world as {name}"); + send_pick(&mut roster, &pick, guid); + (*phase, *mark) = (4, now); + } + None => { + warn!("logout-smoke: empty roster — cannot test re-entry"); + *phase = 5; + } } - None => { - warn!("logout-smoke: empty roster — cannot test re-entry"); - *phase = 5; - } - }, + } 4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => { // The suppressor reading catches a character that re-entered unable to move; // `scripts/smoke.sh` fails on anything but `none`. A GM account's logout is instant, @@ -923,7 +973,7 @@ pub(crate) fn test_character(guid: u64, name: &str) -> Character { pub(crate) fn add_test_systems(app: &mut App, pick: crossbeam_channel::Sender) { app.insert_state(ClientState::CharSelect) .init_resource::() - .insert_resource(CharPick(pick)) + .insert_resource(CharPick(pick.into())) .add_message::() .add_systems( Update, @@ -1058,6 +1108,56 @@ mod tests { ); } + /// A logout, or a refused login, and the roster the kept link sends right behind it can land + /// in one drain: the pick the world was entered with goes first, so the roster is shown and + /// not answered with that pick again. + #[test] + fn a_return_and_its_roster_in_one_drain_show_the_roster() { + for refused in [false, true] { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(ClientState::InWorld) + .insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7)) + .insert_resource(CharPick(tx.into())) + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_systems(Update, session_edges()); + app.world_mut().resource_mut::().env_read = true; + if refused { + app.world_mut() + .write_message(CharacterLoginFailedMessage { result: 0x01 }); + } else { + app.world_mut().write_message(LoggedOutMessage); + } + app.world_mut().write_message(CharListMessage { + characters: vec![character(7, "Seven")], + realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 1, + generation: 0, + }); + app.update(); + app.update(); + + assert_eq!( + *app.world().resource::>().get(), + ClientState::CharSelect, + "refused: {refused}" + ); + assert!( + rx.try_recv().is_err(), + "refused: {refused} — the roster was answered with the pick just left" + ); + assert_eq!(app.world().resource::().pending_pick, None); + } + } + /// Every refusal byte resolves to the sentence 1.12 ships, off the player's chain. `4` is /// `CHAR_LOGIN_DISABLED`: the reference's table skips `CHAR_LOGIN_FAILED` (0x41). #[test] @@ -1199,7 +1299,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .insert_resource(match stored { Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v), None => crate::cvars::Cvars::default(), @@ -1217,6 +1317,9 @@ mod tests { .map(|(i, n)| character(i as u64 + 1, n)) .collect(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); app.world().resource::().selected() @@ -1311,7 +1414,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0")) .add_message::() .add_message::() @@ -1327,6 +1430,9 @@ mod tests { character(3, "Zzbullone"), ], realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); assert_eq!( @@ -1363,7 +1469,7 @@ mod tests { app.add_plugins(MinimalPlugins) .init_resource::() .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .add_systems(Update, persist_last_character); { let mut roster = app.world_mut().resource_mut::(); diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index 68ab0b02..6647aa8c 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -7,7 +7,7 @@ use crate::area::AreaTableRes; use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight}; use crate::glue_strings::GlueStrings; use crate::net::NetStatus; -use crate::portrait::{GlueLook, GluePreview, SelectLook}; +use crate::portrait::{GlueLook, GluePreview, GlueScene, SelectLook}; use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS}; use super::{class_name, Roster}; @@ -170,10 +170,32 @@ pub(super) fn refresh_banner_and_buttons( } } -/// Feed the glue booth: the scene is the selected character's race (`SetBackgroundModel`; Orc -/// with no character, the reference's OnLoad default), the look its geared enum record. +/// The select scene's background as `CharacterSelect` holds it for the glue session: `race` is +/// `None` while only its OnLoad has set the scene (`Model:SetModel(UI_Orc)` under Orc's fog, +/// `CharacterSelect.lua:26-31`), else the race whose file string `SetBackgroundModel` last set +/// (`CharacterSelect.lua:341-344`), drawn unfogged (`0x472110` replaces the fog callback). The +/// call is keyed on the race's file string, not its stage, so a gnome after a dwarf loads the +/// same stage again, a new instance whose scene starts over (`0x76cce0`): `stage` counts them. An +/// emptied roster selects nothing and calls neither, so the scene stays. The glue rebuild at world +/// entry restores OnLoad's. +#[derive(Resource, Default)] +pub(crate) struct SelectBackground { + pub(super) race: Option, + pub(super) stage: u64, +} + +/// The glue rebuild (`0x46a7b0`) runs `CharacterSelect`'s OnLoad again, a new instance. +pub(super) fn reset_select_background(mut background: ResMut) { + background.race = None; + background.stage = background.stage.wrapping_add(1); +} + +/// Feed the glue booth: the scene is the selected character's stage (`SetBackgroundModel`), +/// unfogged; with no character the last stage stays, or the OnLoad Orc scene under its fog stands +/// ([`SelectBackground`]); the look is the selected character's geared enum record. pub(super) fn feed_glue_preview( roster: Res, + mut background: ResMut, mut preview: ResMut, mut showing: Local>, ) { @@ -184,13 +206,25 @@ pub(super) fn feed_glue_preview( *showing = Some(roster.select_seq); preview.yaw = 0.0; } - let (race, look) = match roster.selected_char() { - Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))), - None => (2, None), // UI_Orc, the reference's empty-account scene + let look = roster.selected_char().map(|c| { + if background.race != Some(c.race) { + background.race = Some(c.race); + background.stage = background.stage.wrapping_add(1); + } + GlueLook::Select(SelectLook::from(c)) + }); + let (scene, fog) = match background.race { + Some(race) => (GlueScene::of_race(race), None), + None => (GlueScene::Orc, Some(GlueScene::Orc.stock_fog())), }; - let scene = Some(crate::portrait::GlueScene::Race(race)); - if preview.scene != scene { - preview.scene = scene; + if preview.scene != Some(scene) { + preview.scene = Some(scene); + } + if preview.stage != background.stage { + preview.stage = background.stage; + } + if preview.fog != fog { + preview.fog = fog; } if preview.look != look { preview.look = look; @@ -201,6 +235,99 @@ pub(super) fn feed_glue_preview( mod tests { use super::*; + /// #978: an empty roster's select scene is OnLoad's Orc under Orc's fog; a selected character + /// sets its stage, unfogged, and that stage stays when the roster empties; the glue rebuild at + /// world entry restores OnLoad's. + fn tauren_again() -> benilla_protocol::Character { + let mut c = crate::char_select::test_character(1, "Moo"); + c.race = 6; + c + } + + #[test] + fn the_select_scene_fogs_until_a_background_is_set_and_then_stays() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_glue_preview); + let shown = |app: &App| { + let p = app.world().resource::(); + (p.scene, p.fog.map(|f| f.far), p.look.is_some()) + }; + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false)); + + let mut tauren = crate::char_select::test_character(1, "Moo"); + tauren.race = 6; + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![tauren]; + roster.select(Some(0)); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true)); + + // A gnome after a dwarf: the same stage, set again, starts over (`0x76cce0`). + let stage = |app: &App| app.world().resource::().stage; + let mut dwarf = crate::char_select::test_character(2, "Stone"); + dwarf.race = 3; + let mut gnome = crate::char_select::test_character(3, "Cog"); + gnome.race = 7; + let first = stage(&app); + for (c, row) in [(dwarf, 0), (gnome, 1)] { + let before = stage(&app); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars.insert(row, c); + roster.select(Some(row)); + } + app.update(); + assert_eq!(shown(&app).0, Some(GlueScene::Dwarf)); + assert_ne!( + stage(&app), + before, + "a new race's background is a new instance" + ); + } + // Back to the dwarf, then the same row again: only a race change makes one. + { + let mut roster = app.world_mut().resource_mut::(); + roster.select(Some(0)); + } + app.update(); + let dwarf_again = stage(&app); + assert!(dwarf_again != first); + { + let mut roster = app.world_mut().resource_mut::(); + roster.select(Some(0)); + } + app.update(); + assert_eq!(stage(&app), dwarf_again); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![tauren_again()]; + roster.select(Some(0)); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true)); + + // The last character deleted: nothing is selected, and no background call replaces it. + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars.clear(); + roster.select(None); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, false)); + + app.world_mut() + .run_system_cached(reset_select_background) + .unwrap(); + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false)); + } + /// The composition order of `CharacterSelect.lua:48-64`. #[test] fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() { diff --git a/crates/benilla-app/src/char_select/screen.rs b/crates/benilla-app/src/char_select/screen.rs index 354ce27f..39b3d490 100644 --- a/crates/benilla-app/src/char_select/screen.rs +++ b/crates/benilla-app/src/char_select/screen.rs @@ -70,10 +70,13 @@ const ROW_HIT_H: f32 = 55.0; const ROW_PITCH: f32 = 57.0; /// Entry only resets state: the initial state's `OnEnter` fires at startup, before the MPQ chain -/// and booth slots exist, so the tree spawns in [`materialize_screen`]. +/// and booth slots exist, so the tree spawns in [`materialize_screen`]. The native screen's alone: +/// the stock screens' facing is the glue VM's. pub(super) fn enter_select(mut preview: ResMut) { // The model faces the camera on entry: the reference's facing global defaults to zero. preview.yaw = 0.0; + // `CharacterSelect`'s OnShow (`0x46fa60`). + preview.shown = preview.shown.wrapping_add(1); } /// Spawn the screen tree once the art is loaded, respawn it when art lands after an artless spawn @@ -145,6 +148,7 @@ fn spawn_screen( let root = commands .spawn(( + crate::glue::NativeGlue, CharSelectUi { with_art: art.button_up.is_some(), s, @@ -601,18 +605,21 @@ pub(super) fn hide_under_world_cover( } } +/// Leaving the native select screen drops its scene; the next screen sets its own. +pub(super) fn clear_select_scene(mut preview: ResMut) { + preview.look = None; + preview.scene = None; +} + pub(super) fn exit_select( mut commands: Commands, roots: Query>, - mut preview: ResMut, mut dialog: ResMut, mut glue_dialog: ResMut, ) { for e in &roots { commands.entity(e).despawn(); } - preview.look = None; - preview.scene = None; dialog.close(); // The glue dialog must not follow into the world or the create screen. Its tree is despawned // here, since its driver runs on the glue screens only. diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 1a91b740..088d0749 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -1,13 +1,19 @@ -//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker, a -//! 2-D overlay like the V-plate ([`crate::vplates`]). +//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker. This +//! file owns the world side (gate, lifetime, fade, anchor, projection, stacking order, sizes); the +//! widgets, real `WorldFrame` children an addon can skin, live in +//! [`benilla_ui::script::chat_bubble`]. //! //! - Spawn (`0x608ac0`, from the `SMSG_MESSAGECHAT` display path): the sender resolves to a live //! unit, its CVar is on, the text is non-empty, the unit carries no V-plate (`0x608adc`; a live //! bubble in turn hides the overhead name, [`BubblesActive`]), and the local player is within //! 20 yd (3-D). Self is not excluded. A new line replaces the old bubble, never queues. -//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, bottom-seated, growing upward. The -//! height is the Stand sequence box's Z extent from the model header (`0x711a20`), not the posed -//! attachment the overhead name reads (`0x608640`), latched once per chat line. +//! - Lifetime and fade (`0x4b0c30`): a 250 ms fade in, the line's [`duration_ms`], a 250 ms fade +//! out, then the frame goes back to the pool. Out of range, or with the speaker not drawn in the +//! last render ([`speaker_drawn`]), it fades out and hides, and fades back in on return. +//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, the text's BOTTOM on the projected +//! point (`0x4b157a`), growing upward. The height is the Stand sequence box's Z extent from the +//! model header (`0x711a20`), not the posed attachment the overhead name reads (`0x608640`), +//! latched once per chat line. //! - Stacking (`0x4b1060`): every frame the bubbles sort farthest-first by camera distance and //! take frame levels 2, 3, 4…, so whole cards stack with the nearest speaker's on top. //! @@ -17,25 +23,20 @@ //! Deviation: sizes ride the plates' damped diagonal basis ([`plate_basis`]), not the unbounded //! reference law, so bubble text and plate text keep one em at every window size. //! -//! Not built: the reference also fades a bubble whose speaker's model data is not resident or whose -//! scene-entity flag is clear (`0x7103d0`/`0x6704c0`); only distance is re-tested here. +//! Not built: the reference also ends a bubble whose speaker's model data is not resident +//! (`0x7103d0`); here a speaker with no model yet only counts as not drawn. use std::collections::HashMap; use bevy::ecs::entity::EntityHashSet; use bevy::prelude::*; -use benilla_ui::layout::Rect as GxRect; -use benilla_ui::script::{inset_atlas_bleed, pieces, Backdrop, Insets}; -use benilla_ui::script::{JustifyH, JustifyV, Outline}; +use benilla_ui::script::{BubbleGeometry, BubbleState}; use crate::entities::StandBoxHeight; use crate::net::{Embodied, Guid, NetEntity, SelfGuid}; use crate::ui_chat::{default_color, ChatEventKind}; -use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect}; -use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, TextSeat, UiFontAtlas}; -use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates}; -use benilla_assets::{AssetSet, WorldAssets}; +use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_world::view::WorldCamera; /// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults. @@ -56,40 +57,22 @@ impl Default for BubbleConfig { } } -/// The bubble art (`0x4b0940`). -const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background"; -const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop"; -const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail"; - /// The 0.7 yd lift over the anchor height (`[0x7ffd7c]`). const LIFT: f32 = 0.7; -/// The spawn and per-frame range gate, 20 yd squared (`[0x806798]`, the plates' too). +/// The range gate, 20 yd squared (`[0x806798]`, the plates' too): a spawn passes at or under it +/// (`0x608ba0`), a live bubble stays in range only under it (`0x4b0cd2`). const MAX_DIST_SQ: f32 = 400.0; -/// The 250 ms linear fade, in and out (`0x4b0ea0`/`0x4b0ee0`). -const FADE_SECS: f32 = 0.25; -/// `NAMEPLATE_FONT` at 0.01 gx, the plate name's em. +/// The linear fade, in and out, in ms (`0x4b0d3e`, `0x4b0d78`). +const FADE_MS: u64 = 250; +/// `NAMEPLATE_FONT` at 0.01 gx (`0x4b0b21`), the plate name's em. const TEXT_H: f32 = 0.01; -/// The body margin around the text layout, all four sides (`0x3c23d70a`). +/// The frame's margin around the text, all four sides (`0x3c23d70a`). const MARGIN: f32 = 0.01; /// The text wrap hard cap, gx (`[0x80679c]` = 0.2). const WRAP_W: f32 = 0.2; /// The border unit (edge size, insets, tail side): 16/1024 of the screen width. const BORDER_FRAC: f32 = 16.0 / 1024.0; -/// Paint order inside one bubble's level: the tail covers the bottom edge piece and the text draws -/// last, as the reference's batch drains textures before font strings (`0x76fb00`). -const Z_BG: u64 = 0; -const Z_EDGE: u64 = 1; -const Z_TAIL: u64 = 2; -const Z_TEXT: u64 = 3; - -/// The z base for the bubble at `rank`, farthest first: the reference's per-frame -/// `SetFrameLevel(2 + i)` walk (`0x4b12d5`-`0x4b1312`), one level per bubble, under the V-plates. -fn level_z(rank: usize) -> u64 { - let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL); - overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE -} - /// The CVar gating this kind's bubble (`0x608b0d`); `None` for a kind that does not bubble. fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { use ChatEventKind as K; @@ -100,87 +83,73 @@ fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { } } -/// The word count (`0x4b1810`): maximal runs of characters other than space and tab. -fn word_count(text: &str) -> u32 { - let mut words = 0u32; - let mut in_word = false; - for b in text.bytes() { - let sep = b == b' ' || b == b'\t'; - if !sep && !in_word { - words += 1; - } - in_word = !sep; - } - words -} - -/// The lifetime (`0x4b1810`): `base + perWord·(words−1)` ms, 2750/750 for others and 1500/500 -/// for the local player. -fn duration_secs(words: u32, is_self: bool) -> f32 { - if words == 0 { - return 0.0; +/// The lifetime in ms (`0x4b1810`) over the displayed text: the base, plus the per-word step for +/// each space or tab that ends a word, 2750/750 for others and 1500/500 for the local player. An +/// empty text lasts 0; separators alone, the base. +fn duration_ms(text: &str, is_self: bool) -> u64 { + if text.is_empty() { + return 0; } let (base, per) = if is_self { (1500, 500) } else { (2750, 750) }; - (base + per * (words - 1)) as f32 / 1000.0 + let mut ms = base; + // Starts as if after a separator, so a leading run adds nothing. + let mut after_sep = true; + for b in text.bytes() { + let sep = b == b' ' || b == b'\t'; + if sep && !after_sep { + ms += per; + } + after_sep = sep; + } + ms } -/// Bubble text is plain, coloured only by chat type: colour escapes and hyperlink wrappers strip -/// (their display text stays), and `||` stays escaped for the glyph layout's markup parser. +/// The bubble's copy of the line (`0x4b1600`, into a 0x800-byte buffer): `||` stays escaped for the +/// text's markup parser; any other `|` jumps to the next `[`, or ends the text if there is none; +/// from there the brackets drop, and a `|h` then a `|r` right after the `]` skip. So a link shows +/// its bare name and a colour code with no link after it cuts the line short. fn sanitize(text: &str) -> String { + const BUF: usize = 0x800 - 1; let b = text.as_bytes(); - let mut out = String::with_capacity(text.len()); + let mut out: Vec = Vec::with_capacity(b.len()); let mut i = 0; - while i < b.len() { + while i < b.len() && out.len() < BUF { if b[i] != b'|' { - // Copy the whole non-escape run at once (UTF-8 safe: '|' is single-byte). - let start = i; - while i < b.len() && b[i] != b'|' { - i += 1; - } - out.push_str(&text[start..i]); + out.push(b[i]); + i += 1; continue; } - match b.get(i + 1) { - Some(b'|') => { - out.push_str("||"); - i += 2; - } - Some(b'c') | Some(b'C') if i + 10 <= b.len() => i += 10, - Some(b'r') | Some(b'R') => i += 2, - Some(b'H') => { - // Skip the opener through its `|h`; the closing `|h` drops in the arm below. - i += 2; - while i < b.len() && !(b[i] == b'|' && b.get(i + 1) == Some(&b'h')) { + if b.get(i + 1) == Some(&b'|') { + out.extend_from_slice(b"||"); + i += 2; + continue; + } + let Some(open) = b[i..].iter().position(|&c| c == b'[') else { + break; + }; + i += open; + while i < b.len() { + match b[i] { + b'[' => i += 1, + b']' => { + i += 1; + for skip in [b"|h", b"|r"] { + if b[i..].starts_with(skip) { + i += 2; + } + } + break; + } + c => { + out.push(c); i += 1; } - i += 2; - } - Some(b'h') => i += 2, - // A dangling or unknown escape renders as a literal pipe. - _ => { - out.push_str("||"); - i += 1; } } } - out -} - -/// The bleed inset for one border piece; zero texels (no patch chain) leaves the UVs alone. -fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] { - if texels.x <= 0.0 || texels.y <= 0.0 { - return uvs; - } - inset_atlas_bleed(uvs, texels.x, texels.y) -} - -/// The frame's bottom-left origin, bottom-center on the seat. Deviation: snapped onto the device -/// pixel grid ([`device_snap`]) so the border art blits 1:1; the reference seats it fractionally. -fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 { - Vec2::new( - device_snap(seat.x - w * 0.5, scale), - device_snap(seat.y, scale), - ) + out.truncate(BUF); + // Cut only at ASCII bytes, so whole characters, but for the buffer cap. + String::from_utf8_lossy(&out).into_owned() } /// The border unit in logical px: 16/1024 of the screen width, in the damped size basis. @@ -221,69 +190,218 @@ impl BubbleQueue { } } +/// `[bubble+0x320]` bit 1: fading in. +const FADING_IN: u8 = 0x1; +/// Bit 2: fading out. +const FADING_OUT: u8 = 0x2; +/// Bit 4: the fade out is the last, at the end of the line's life. +const FINAL: u8 = 0x4; + +/// A bubble's fade (`0x4b0ea0`, `0x4b0ee0`, the middle of `0x4b0c30`): the flags, the fade's start +/// time, the frame's alpha byte and its shown bit. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Fade { + flags: u8, + /// `[bubble+0x33c]`, the current fade's start. + since: u64, + /// The frame's alpha byte (`+0xc8`); a new frame starts opaque. + alpha: u8, + /// The own-shown bit (`+0xd0`). + shown: bool, +} + +impl Fade { + /// A line's start (`0x4b17da`, then `0x4b17f7`): the flags reset, then the fade in. The alpha + /// is a new frame's (`0x7690d2`); a reused frame keeps what its last fade left, which this + /// does not track, and which matters only to a line whose first frame fades out. + fn spawn(now: u64) -> Self { + let mut f = Fade { + flags: 0, + since: now, + alpha: 255, + shown: true, + }; + f.fade_in(now); + f + } + + /// `0x4b0ea0`: unless fading in or on the last fade out, start fading in and show. + fn fade_in(&mut self, now: u64) { + if self.flags & (FADING_IN | FINAL) != 0 { + return; + } + self.flags = (self.flags & !FADING_OUT) | FADING_IN; + self.since = now; + self.shown = true; + } + + /// `0x4b0ee0`: unless on the last fade out, mark it final if asked, and start fading out + /// unless already. + fn fade_out(&mut self, now: u64, last: bool) { + if self.flags & FINAL != 0 { + return; + } + if last { + self.flags |= FINAL; + } + if self.flags & FADING_OUT == 0 { + self.flags = (self.flags & !FADING_IN) | FADING_OUT; + self.since = now; + } + } + + /// One frame of `0x4b0c30` past its residency gates; `false` when the last fade out ends and + /// the bubble goes back to the pool. + fn tick(&mut self, now: u64, in_range: bool, born: u64, duration: u64) -> bool { + if !in_range { + if self.alpha != 0 { + self.fade_out(now, false); + } + } else if self.alpha != 255 { + self.fade_in(now); + } + if self.flags & FADING_OUT == 0 && now.saturating_sub(born) > duration + FADE_MS { + self.fade_out(now, true); + } + // `elapsed · 255 / 250`, truncated (`0x4b0d5b`-`0x4b0d68`). + let ramp = |e: u64| (e * 255 / FADE_MS) as u8; + let e = now.saturating_sub(self.since); + if self.flags & FADING_IN != 0 { + if e > FADE_MS { + self.alpha = 255; + self.flags &= !FADING_IN; + } else { + self.alpha = ramp(e); + } + } else if self.flags & FADING_OUT != 0 { + if e > FADE_MS { + if self.flags & FINAL != 0 { + return false; + } + // An out-of-range fade ends hidden (`0x4b0d95`). + self.flags &= !(FADING_OUT | FINAL); + self.shown = false; + } else { + self.alpha = 255 - ramp(e); + } + } + true + } +} + /// One live bubble: the `CGChatBubbleFrame` and its `CGUnit+0xe64` handle. struct Bubble { + /// This line's number, which tells the widget layer a replacement from a repeat. + line: u64, /// The [`sanitize`]d display text. text: String, /// The chat-type colour (the 94-entry table), sRGB 0..1. color: [f32; 3], - /// `Time::elapsed_secs` at spawn. - born: f32, - /// The steady window after the fade-in ([`duration_secs`]). - duration: f32, + /// The spawn time in ms (`[bubble+0x338]`). + born: u64, + /// The steady window after the fade-in ([`duration_ms`], `[bubble+0x340]`). + duration: u64, /// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`). lift: f32, - /// Fade alpha 0..1, ramped toward the eligibility verdict. - alpha: f32, + fade: Fade, + /// The squared camera distance the bubble was last stacked by (`[bubble+0x350]`); `None` for + /// a new line, which the next pass stacks (the spawn flag, `[bubble+0x320] & 0x8`). + depth: Option, } -/// The live bubbles by speaker guid, at most one per unit. +/// Whether the speaker was drawn in the last render: the reference's scene-node test `0x6704c0` +/// (`node && [node+0x94] == 0`, read at `0x4b0cab`). Every frame sets `[node+0x94]` on every node +/// in the scene list `[0xca7d70]` (`0x698a07`) and clears it only for a node that passes the +/// frustum test `0x682ef0` and the horizon test `0x686000` (`0x6833dd`-`0x6833ec`, and +/// `0x68359b`-`0x6835aa` in WMO groups). Here it is any of the unit's meshes passing last frame's +/// visibility check; benilla has no horizon cull yet, so that is the frustum verdict alone. +fn speaker_drawn( + unit: Entity, + children: &Query<&Children>, + meshes: &Query<&ViewVisibility, With>, +) -> bool { + children + .get(unit) + .is_ok_and(|kids| kids.iter().any(|c| meshes.get(c).is_ok_and(|v| v.get()))) +} + +/// The live bubbles by speaker guid, at most one per unit, their stacking, and the next line's +/// number. #[derive(Resource, Default)] -struct Bubbles(HashMap); +struct Bubbles { + live: HashMap, + /// The stacking, farthest first (the list at `0xb6f038`); the index is the frame level. + stack: Vec, + /// The camera position the stack was last sorted from (`0xb6eff0`, held once `0xb6effc` is + /// set). + eye: Option, + next_line: u64, +} + +impl Bubbles { + /// A new line from `guid`: it replaces the speaker's old bubble, which goes back to the pool + /// (`0x608bb4` → `0x608c00`), and the new one is linked at the stack's tail (`0x608bbf` → + /// `0x4b1870`, `0x4b18f0`), unstacked until the next pass. + fn spawn(&mut self, guid: u64, bubble: Bubble) { + self.stack.retain(|g| *g != guid); + self.stack.push(guid); + self.live.insert(guid, bubble); + } + + /// The stacking step of the bubble pass (`0x4b1060`). The camera moved when any coordinate + /// differs from the held one, compared exactly (`0x4b10a5`–`0x4b10d3`); then every bubble is + /// re-stacked, else only a new line (`0x4b11c7`), each from the held position to its speaker + /// (`pos`), into the stack before the first one nearer (`0x4b1360`). The rest keep their + /// place, so a speaker walking under a still camera keeps its stacking. + fn restack(&mut self, camera: Option, pos: impl Fn(u64) -> Option) { + let moved = camera.is_some() && camera != self.eye; + if moved { + self.eye = camera; + } + let Self { + live, stack, eye, .. + } = self; + stack.retain(|g| live.contains_key(g)); + // Pulled out in stack order, then each re-inserted (`0x4b11d7`, `0x4b1220`). + let dirty: Vec = stack + .iter() + .copied() + .filter(|g| moved || live[g].depth.is_none()) + .collect(); + stack.retain(|g| !dirty.contains(g)); + for g in dirty { + let Some(at) = pos(g) else { + continue; + }; + let d = eye.map_or(0.0, |e| e.distance_squared(at)); + if let Some(b) = live.get_mut(&g) { + b.depth = Some(d); + } + let slot = stack + .iter() + .position(|o| live[o].depth.is_some_and(|od| od < d)) + .unwrap_or(stack.len()); + stack.insert(slot, g); + } + } +} + +/// This frame's live bubbles for the widget layer, farthest from the camera first, and the sizes +/// the window gives them; `None` with no viewport to lay out against. [`crate::ui_chat::bubbles`] +/// hands them to the VM. +#[derive(Resource, Default)] +pub(crate) struct BubbleFeed { + pub(crate) geometry: Option, + pub(crate) states: Vec, +} /// Units with a live bubble this frame, whose overhead name is suppressed (`+0xe64` ≠ 0). #[derive(Resource, Default)] pub(crate) struct BubblesActive(pub(crate) EntityHashSet); -/// The bubble art, loaded at boot. The edge strip tiles UVs past 1 (repeat); bg and tail clamp. -#[derive(Resource)] -struct BubbleArt { - bg: Handle, - edge: Handle, - tail: Handle, - /// The edge atlas's size in texels, for the half-texel bleed inset. - edge_texels: Vec2, -} - -fn load_bubble_art( - mut commands: Commands, - assets: Option>, - mut images: ResMut>, -) { - let Some(mut assets) = assets else { - return; // no game data (a bare test app); `drive_bubbles` tolerates the missing art - }; - let bg = assets.sprite_texture(BG_TEXTURE, &mut images); - let edge = assets.sprite_texture_tiled(EDGE_TEXTURE, &mut images); - let tail = assets.sprite_texture(TAIL_TEXTURE, &mut images); - let (Some(bg), Some(edge), Some(tail)) = (bg, edge, tail) else { - warn!("chat_bubble: bubble art missing from the patch chain — bubbles will not draw"); - return; - }; - let edge_texels = images.get(&edge).map_or(Vec2::ZERO, |i| { - let s = i.texture_descriptor.size; - Vec2::new(s.width as f32, s.height as f32) - }); - commands.insert_resource(BubbleArt { - bg, - edge, - tail, - edge_texels, - }); -} - -/// Every frame: drain the queue through the spawn gate (`0x608ac0`), ramp each bubble's fade -/// against its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and draw. +/// Every frame: drain the queue through the spawn gate (`0x608ac0`), run each bubble's fade against +/// its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and lay out the +/// live set for the widget layer ([`BubbleFeed`]), farthest from the camera first. #[allow(clippy::type_complexity)] // one Bevy system's full input set fn drive_bubbles( mut queue: ResMut, @@ -294,17 +412,17 @@ fn drive_bubbles( units: Query<(Entity, &Guid, &Transform), With>, self_q: Query<&Transform, With>, camera: Query<(&Camera, &Transform), With>, - mut atlas: Option>, - mut quads: ResMut, - art: Option>, heights: Query<&StandBoxHeight>, time: Res