From 99b5600e250d2da2e26461dd33361b8d2d84458b Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 Sep 2026 16:35:03 +0700 Subject: [PATCH] idle sits you down and camps you, and the addons anchor, parse and draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - five idle minutes sit you down and flag you AFK, thirty log you out through the ordinary countdown; holding a key is idle, moving the mouse is not, and a swimmer stays afloat (2092) - a knockdown no longer wedges: the landing froze the clip that held the base-animation lock, leaving a knocked-down player on their back for the session (2098); the perf cost pill starts hidden (2099) - Lua is exactly 1.12's 5.0 — no vararg expression, no #, no %, the remembered table size — so Cartographer loads without errors and Mik's Scrolling Battle Text shows its text, in its own shipped font now that addon TTFs load (2101, 2102, 2103) - a widget name is a Lua global for SetPoint and its kin, so Bartender2's bars spread out; a button's NormalText disowns its justify, the raise renumbers hidden frames too, and a missing toc file warns instead of erroring (2105, 2100, 2104, 2107, 2106) Co-Authored-By: Claude Fable 5.1 --- Cargo.toml | 21 +- crates/benilla-app/src/addon_harness/mod.rs | 213 ++++- crates/benilla-app/src/addon_harness/probe.rs | 16 +- crates/benilla-app/src/capture/mod.rs | 3 +- crates/benilla-app/src/capture/probes/act.rs | 4 + .../benilla-app/src/creature_anim/driver.rs | 20 +- .../src/creature_anim/driver/play.rs | 63 +- .../src/creature_anim/driver/tests.rs | 104 +++ crates/benilla-app/src/perf/hud.rs | 26 +- crates/benilla-app/src/player.rs | 9 + crates/benilla-app/src/ui_chat.rs | 18 +- crates/benilla-app/src/ui_chat/feed.rs | 15 + crates/benilla-app/src/ui_chat/idle.rs | 779 ++++++++++++++++++ crates/benilla-app/src/ui_logout.rs | 99 ++- crates/benilla-app/src/ui_script/addons.rs | 135 ++- .../src/ui_script/delete_item_tests.rs | 5 +- crates/benilla-app/src/ui_script/lifecycle.rs | 52 +- .../src/ui_script/multibar_stance_tests.rs | 9 +- crates/benilla-app/src/ui_text/engine/mod.rs | 181 +++- crates/benilla-app/tests/item_text_frame.rs | 2 +- crates/benilla-assets/src/world_assets/mod.rs | 29 +- crates/benilla-ui/src/loader/geometry.rs | 3 +- crates/benilla-ui/src/loader/regions.rs | 66 +- crates/benilla-ui/src/loader/tests.rs | 137 +++ crates/benilla-ui/src/loader/widgets.rs | 76 +- crates/benilla-ui/src/script/addon.rs | 40 +- crates/benilla-ui/src/script/diagnostics.rs | 27 +- crates/benilla-ui/src/script/font_block.rs | 8 +- crates/benilla-ui/src/script/lua50.rs | 180 ++-- .../benilla-ui/src/script/lua50/table_size.rs | 505 ++++++++++++ crates/benilla-ui/src/script/mod.rs | 15 +- crates/benilla-ui/src/script/model.rs | 18 + crates/benilla-ui/src/script/object.rs | 105 +++ .../src/script/object/frame_state.rs | 93 ++- .../src/script/object/layout_methods.rs | 89 +- .../benilla-ui/src/script/object/toplevel.rs | 17 +- crates/benilla-ui/src/script/region.rs | 109 ++- crates/benilla-ui/src/script/region/layout.rs | 4 +- crates/benilla-ui/src/script/region/paint.rs | 8 + crates/benilla-ui/src/script/scrollframe.rs | 17 +- crates/benilla-ui/src/script/stdlib.rs | 44 +- crates/benilla-ui/src/script/tests/events.rs | 9 +- .../src/script/tests/font_object.rs | 52 ++ crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/name_targets.rs | 244 ++++++ .../src/script/tests/reference_surface.rs | 86 ++ crates/benilla-ui/src/script/tests/stdlib.rs | 24 +- .../src/script/tests/tooltip_item/bindings.rs | 18 +- .../benilla-ui/src/script/tests/toplevel.rs | 112 +++ crates/benilla-ui/src/widget/propagation.rs | 63 +- third_party/lua-src/BENILLA.md | 32 +- third_party/lua-src/lua-5.1.5/lparser.c | 67 +- 52 files changed, 3713 insertions(+), 359 deletions(-) create mode 100644 crates/benilla-app/src/ui_chat/idle.rs create mode 100644 crates/benilla-ui/src/script/lua50/table_size.rs create mode 100644 crates/benilla-ui/src/script/tests/name_targets.rs diff --git a/Cargo.toml b/Cargo.toml index 6269cd90..78a166f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,16 +46,19 @@ sha1 = "0.10.6" # section stood empty for the project's whole life. # # The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's -# addon VM runs on, and the 1.12 corpus needs one opcode that Lua 5.1 deleted — the iterator-less -# generic-for (`for k, v in someTable do`), which 183 of 218 corpus addons are reached by and which -# is the first session-start error for 60 of them. It is an OPCODE, so no layer above the VM can -# reach it: not a library function, not a global, not a metamethod. +# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua +# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM: +# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which +# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat +# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are +# below the reach of any layer above the VM — an opcode and a grammar are not a library function, +# a global, or a metamethod — and the corpus ASKS about the second direction directly: 170 +# `loadstring` sites compile a vararg expression purely to find out which interpreter they are on. # -# The fork is currently BYTE-IDENTICAL to upstream's 5.1.5 sources, and is landed that way on -# purpose: the pipeline is proven transparent before the behavioural payload goes into it, so that -# when the suite moves, the patch is the only thing that could have moved it. The payload waits on -# an RE answer about what the real client's own Lua does — stock Lua 5.0 is corroboration, not the -# authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command. +# The fork landed BYTE-IDENTICAL to upstream's 5.1.5 sources on purpose: the pipeline was proven +# transparent before any behavioural payload went into it, so when the suite moved, the patch was +# the only thing that could have moved it. `third_party/lua-src/BENILLA.md` has the current state, +# the per-hunk byte evidence and the verifying command (decisions 1215, 1315, 2101). [patch.crates-io] lua-src = { path = "third_party/lua-src" } # kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 76cbae11..18626ad6 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -507,9 +507,13 @@ fn survey_one( script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET); script.set_screen_size(1024.0, 768.0); // Before anything runs: the AddOn API must answer for the whole installed set, not for nothing. - // `None` roots because a survey must never read or write the director's real saved variables - // (the same reason `drive_session_start` is not `finish_ui_load` — 1213 §4). - script.register_addons(registry.to_vec(), None, None, None); + // The **saved-variable** roots are `None` — a survey must never read or write the director's + // real ones (the same reason `drive_session_start` is not `finish_ui_load`, 1213 §4). The + // ADDONS root is not one of those and is passed in full (decision 2102): without it every + // `LoadAddOn("")` answered `MISSING`, which is a state the real client cannot + // produce and is exactly the 1193/1212 fault one level up — `/msbt`'s + // `UIParentLoadAddOn("MikScrollingBattleTextOptions")` is the shape it hides. + script.register_addons(registry.to_vec(), Some(root.to_path_buf()), None, None); seat_a_session(&mut script); let _ = crate::ui_script::load_default_ui(&script); // The addon's DEPENDENCIES, first and recursively — `AddOn_Load 0x51f240`'s own first two @@ -547,12 +551,20 @@ fn survey_one( let baseline = RenderBaseline::of(&script); let (errors, absent) = load_addon_files(&script, root, name, &toc); + // The registry has to agree with the VM about what has loaded: the live walk stamps each + // addon at `0x51f5ad`'s position (`ui_script::addons`'s `mark_addon_loaded`, just before + // `ADDON_LOADED`), and without it here `IsAddOnLoaded` answered nil for an addon whose files + // had just run — and a LoadOnDemand dependent of it got `DEP_NOT_DEMAND_LOADED` rather than + // loading (decision 2102). The dependency chain is stamped too, in load order. + for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) { + script.mark_addon_loaded(loaded); + } let wants = missing_calls(root, name, &toc, &known, &dep_methods); // AFTER the addon's files: a template it declares in its OWN XML is registered by then, so it // is not missing. The check asks the VM's live registry, not a name list. 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, &dep_order); + let session_errors = drive_session_start(&mut script, name, &dep_order, installed); let probe_errors = drive_ui_probe(&mut script); // AFTER the UI probe and BEFORE the method oracle, and both halves of that are load-bearing. // After, because the probe leaves the addon fully driven — and this pass re-OPENS what the @@ -886,7 +898,12 @@ fn only_on(have: &[String], probes: &[String]) -> String { /// second reaches the common `ScheduleEvent(..., 0)`/`(..., 0.05)` shapes and Ace's own one-second /// `AceEvent_FullyInitialized` timer; it does not reach a ten-second self-heal, and that /// under-report is stated rather than hidden. -fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Vec { +fn drive_session_start( + script: &mut UiScript, + name: &str, + deps: &[String], + installed: &BTreeSet, +) -> Vec { // **Every addon in the VM gets its OWN `ADDON_LOADED`, in load order** — the client fires one // per loaded addon with that addon's folder in `arg1`, and a dependency's initialiser is // almost always gated on exactly that: @@ -938,34 +955,91 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve let raised = script.errors().split_off(before); raised .into_iter() - .filter(|e| !raised_inside_a_dependencys_own_file(e, name, deps)) + .filter(|e| !raised_inside_another_addons_own_file(e, name, installed)) .collect() } -/// Does this raise belong to a DEPENDENCY's file rather than the surveyed addon's? +/// Does this raise belong to **another installed addon's** file rather than the surveyed addon's? /// -/// The rule `load_dependencies` already states for load errors, now enforceable for session errors -/// too: *a library that fails is its own row; blaming its consumers would count one fault once per -/// addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the frames -/// below it are the call path, and a consumer calling into a library that then raises is still the -/// library's fault, exactly as it is at load time. +/// The rule `load_dependencies` already states for load errors, enforceable for session errors +/// since 1226: *a library that fails is its own row; blaming its consumers would count one fault +/// once per addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the +/// frames below it are the call path, and a consumer calling into a library that then raises is +/// still the library's fault, exactly as it is at load time. /// -/// **Conservative on purpose.** A chunk that names no addon folder at all — our own FrameXML, an -/// `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what drove it. Only -/// a chunk that positively names one of this addon's own dependencies is dropped. Getting that -/// backwards would swing the column the other way, and the whole point of 1226 is that a proxy -/// which errs silently in one direction is how the number drifted in the first place. -fn raised_inside_a_dependencys_own_file(err: &str, name: &str, deps: &[String]) -> bool { - let Some(first) = err.lines().next() else { +/// **The exemption was "a DECLARED DEPENDENCY's file" and that was too narrow** (decision 2107). +/// Once the harness's VMs got a real AddOns root, `LoadAddOn` began doing what it does in a real +/// session, and the corpus's largest family drives it deliberately: `FuBar.lua:1034`'s +/// `LoadLoadOnDemandPlugins` demand-loads **every** installed `FuBar_*` plugin the moment any one +/// of them seats. So surveying `FuBar_MoneyFu` runs fifty *siblings*' file scope, and +/// `FuBar_BattlegroundFu`'s embedded `Glory-2.0` raises `Glory-2.0 requires Deformat-2.0` — +/// a library `FuBar_BattlegroundFu` does not ship and that a dozen of its siblings do, which is +/// this module's own stated one-VM-per-addon limitation and not a gap of ours. It is not +/// `FuBar_MoneyFu`'s row either: it is `FuBar_BattlegroundFu`'s, where the survey already counts it +/// once. **49 of the corpus's 219 addons were failing on somebody else's file.** +/// +/// A demand-loaded sibling is the same category as a declared dependency — code the surveyed addon +/// did not write — so the test is now "an installed addon that is not this one", which subsumes +/// `deps` (every dependency is installed) and adds the siblings. +/// +/// **Conservative on purpose, unchanged.** A chunk that names no addon folder at all — our own +/// FrameXML, an `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what +/// drove it. And the surveyed addon's own folder is tested FIRST, so a raise inside its own +/// `Libs\Ace\…` is never handed to an installed addon that happens to be called `Ace`. +fn raised_inside_another_addons_own_file( + err: &str, + name: &str, + installed: &BTreeSet, +) -> bool { + let Some(first) = err.lines().next().map(str::to_ascii_lowercase) else { return false; }; - // The surveyed addon's own folder always wins, even when a dependency's name is a substring of - // a path inside it. - if first.contains(&format!("\\{name}\\")) || first.starts_with(&format!("{name}\\")) { + let owns = |line: &str, folder: &str| { + line.contains(&format!("\\{folder}\\")) + || line.contains(&format!("{folder}/")) + || line.starts_with(&format!("{folder}\\")) + }; + let mine = name.to_ascii_lowercase(); + // The surveyed addon's own folder always wins, even when another addon's name is a substring + // of a path inside it. + if owns(&first, &mine) { return false; } - deps.iter() - .any(|d| first.contains(&format!("\\{d}\\")) || first.starts_with(&format!("{d}\\"))) + if installed.iter().any(|d| owns(&first, d)) { + return true; + } + raised_inside_a_demand_load(err, &mine) +} + +/// The raise site names **no file at all** — did it happen inside a `LoadAddOn` this addon made? +/// +/// An inline ``/`` body is compiled under the FRAME's name +/// (`[string "AuctioneerFrame:OnEvent"]`), not the file's, so the folder test above cannot see +/// whose code it is — and 1226's conservative half then KEEPS it, charged to whoever drove it. +/// That is right for a handler the surveyed addon's own events reached, and wrong for one reached +/// through a demand load, which is somebody else's addon loading its own frames. +/// +/// The traceback says which. `Stubby.lua:581`'s `inspectAddOn` calls `LoadAddOn` on every addon it +/// finds, Auctioneer's `AuctioneerFrame:OnEvent` raises inside that call, and the frame between +/// them is `[C]: in function 'LoadAddOn'`. So: walk the traceback from the raise site down, and if +/// a `LoadAddOn` frame comes **before** any frame naming the surveyed addon's own folder, the code +/// that raised belongs to the addon being loaded. It cost six addons their row — Stubby and the +/// five that pull it in — for one raise in Auctioneer, which has its own row already. +/// +/// **The false negative, stated:** the surveyed addon's OWN inline handler, fired by an event that +/// happened to be dispatched inside a demand load it made, is exempted too. That is the same +/// direction the dependency exemption has always erred in, and the alternative — charging every +/// addon that calls `LoadAddOn` for its neighbours' code — is what this replaces. +fn raised_inside_a_demand_load(err: &str, mine: &str) -> bool { + for line in err.lines().skip(1).map(str::to_ascii_lowercase) { + if line.contains(&format!("\\{mine}\\")) || line.contains(&format!("{mine}/")) { + return false; // our own frame is above the boundary: our raise + } + if line.contains("in function 'loadaddon'") { + return true; + } + } + false } /// Invoke the reference's own UI entry points, so an addon's OVERRIDES actually execute. @@ -2914,6 +2988,97 @@ mod tests { ); } + /// **A raise in a DEMAND-LOADED SIBLING is that sibling's row, not the surveyed addon's** + /// (decision 2107) — the FuBar shape, verbatim from the corpus. + /// + /// `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins` demand-loads every installed `FuBar_*` the + /// moment any one of them seats, so surveying `FuBar_MoneyFu` runs `FuBar_BattlegroundFu`'s + /// file scope. Its embedded `Glory-2.0` wants a `Deformat-2.0` a dozen of its siblings ship and + /// it does not — this module's one-VM-per-addon limitation, and `FuBar_BattlegroundFu`'s own + /// row, where the survey counts it once. It cost 49 addons their session-start row. + #[test] + fn a_raise_in_another_installed_addons_file_is_that_addons_row() { + let installed: BTreeSet = ["fubar", "fubar_moneyfu", "fubar_battlegroundfu", "ace"] + .into_iter() + .map(str::to_string) + .collect(); + let sibling = "LoadAddOn: FuBar_BattlegroundFu/lib\\Glory-2.0\\Glory-2.0.lua: runtime \ + error: ...Ons\\FuBar_BattlegroundFu\\lib\\Glory-2.0\\Glory-2.0.lua:23: \ + Glory-2.0 requires Deformat-2.0"; + assert!( + raised_inside_another_addons_own_file(sibling, "FuBar_MoneyFu", &installed), + "a sibling's own file is the sibling's row" + ); + // The surveyed addon's OWN file is always its own row — including a library it embeds + // whose folder name is itself an installed addon. + let mine = "runtime error: Interface\\AddOns\\FuBar_MoneyFu\\Libs\\Ace\\Ace.lua:8: boom"; + assert!(!raised_inside_another_addons_own_file( + mine, + "FuBar_MoneyFu", + &installed + )); + // A chunk naming no addon folder at all is KEPT — the surveyed addon drove it. + let ours = "runtime error: [string \"Frame:OnEvent\"]:2: attempt to index a nil value"; + assert!(!raised_inside_another_addons_own_file( + ours, + "FuBar_MoneyFu", + &installed + )); + // A folder that is not installed is not somebody else's row either. + let stranger = "runtime error: Interface\\AddOns\\NotInstalled\\x.lua:1: boom"; + assert!(!raised_inside_another_addons_own_file( + stranger, + "FuBar_MoneyFu", + &installed + )); + } + + /// **A raise inside a `LoadAddOn` the surveyed addon made is the LOADED addon's row** — even + /// when the raise site names no file at all (decision 2107). + /// + /// An inline `` body is compiled under the frame's name, so the folder test is blind + /// to it. `Stubby.lua:581` demand-loads every addon it finds; Auctioneer's + /// `AuctioneerFrame:OnEvent` raises inside that call. Six addons — Stubby and the five that + /// pull it in — were failing on that one raise, which Auctioneer's own row already carries. + #[test] + fn a_raise_inside_a_demand_load_belongs_to_the_addon_being_loaded() { + let installed: BTreeSet = ["stubby", "auctioneer", "enchantrix"] + .into_iter() + .map(str::to_string) + .collect(); + let through_loadaddon = + "runtime error: [string \"AuctioneerFrame:OnEvent\"]:4: attempt to \ + index field 'Core' (a nil value)\n\ + stack traceback:\n\ + \t[C]: in ?\n\ + \t[string \"AuctioneerFrame:OnEvent\"]:4: in function <...>\n\ + \t[C]: in function 'LoadAddOn'\n\ + \tInterface\\AddOns\\Stubby\\Stubby.lua:581: in upvalue 'inspectAddOn'"; + assert!( + raised_inside_another_addons_own_file(through_loadaddon, "Stubby", &installed), + "the LoadAddOn frame sits between the raise and Stubby's own code" + ); + assert!( + raised_inside_another_addons_own_file(through_loadaddon, "Enchantrix", &installed), + "…and the same for an addon that only pulled Stubby in" + ); + // The surveyed addon's OWN frame ABOVE the boundary keeps the raise: it drove it itself. + let ours_first = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\ + stack traceback:\n\ + \tInterface\\AddOns\\Stubby\\Stubby.lua:12: in function 'Stubby.Thing'\n\ + \t[C]: in function 'LoadAddOn'"; + assert!(!raised_inside_another_addons_own_file( + ours_first, "Stubby", &installed + )); + // No LoadAddOn anywhere: 1226's conservative KEEP is untouched. + let plain = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\ + stack traceback:\n\ + \t[C]: in ?"; + assert!(!raised_inside_another_addons_own_file( + plain, "Stubby", &installed + )); + } + /// mlua's `[string "Frame:OnLoad"]:2:` chunk name is a position, not words. #[test] fn a_chunk_name_position_is_not_mistaken_for_the_message() { diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index cbec47e0..c87e0269 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -89,9 +89,11 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option }; script.set_instruction_budget(super::ADDON_INSTRUCTION_BUDGET); script.set_screen_size(1024.0, 768.0); - // `None` roots: a probe must never read or write the director's real saved variables, for the - // same reason the survey does not call `finish_ui_load` (1213 §4). - script.register_addons(registry, None, None, None); + // `None` **saved-variable** roots: a probe must never read or write the director's real ones, + // for the same reason the survey does not call `finish_ui_load` (1213 §4). The AddOns root is + // passed, exactly as [`super::survey_one`] passes it — a probe VM that answers `MISSING` to + // every `LoadAddOn` is not the VM the row came from (decision 2102). + script.register_addons(registry, Some(root.to_path_buf()), None, None); super::seat_a_session(&mut script); let _ = crate::ui_script::load_default_ui(&script); @@ -106,7 +108,13 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option ); let (load_errors, _absent) = super::load_addon_files(&script, root, name, &toc); - let session_errors = super::drive_session_start(&mut script, name, &dep_order); + // Same stamp the survey applies, for the same reason: the registry must agree with the VM + // about what is loaded, or `IsAddOnLoaded` and every dependency verdict answer for a session + // that does not exist. + for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) { + script.mark_addon_loaded(loaded); + } + let session_errors = super::drive_session_start(&mut script, name, &dep_order, &installed); let answers = evals .iter() diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 51fefee7..c0adcb02 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -890,7 +890,8 @@ fn pin_scene( roots: Query<&Transform, Without>, mut cam: Query<&mut Transform, With>, ) { - perf.visible = false; // the perf HUD is default-on; suppress it for a pristine, UI-free shot + perf.visible = false; // hidden by default since 2099, but a session may have chorded it up — + // a capture is pristine and UI-free whatever the run did // A glue screen has no world to light, no clock to pin and no camera to place. The shutter // above needs none of that — it is watching the framebuffer. let Some(scenario) = ctx.scenario else { diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index ac3a5b5f..48ee2e5d 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -191,6 +191,10 @@ fn probe_key_by_name(name: &str) -> Option { "F" => KeyCode::KeyF, "Ctrl" => KeyCode::ControlLeft, "Shift" => KeyCode::ShiftLeft, + // The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`). Reachable no other + // way — it is a host chord, not a binding or a Lua verb — and since the pill starts hidden + // (2099) a probe that wants to see it in a shot has to press it. + "P" => KeyCode::KeyP, // The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole // observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command // and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index 329d56a6..605be562 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -48,7 +48,7 @@ mod tests; mod wound; pub(super) use grip::drive_hand_grip; -use play::{oneshot_finished, play_clip, roll_loop, roll_oneshot}; +use play::{holds_own_clip, oneshot_finished, play_clip, roll_loop, roll_oneshot}; use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge}; /// The weight of a masked upper-body one-shot overlay ([`AnimDriver::overlay`]) over the base clip on @@ -1126,9 +1126,14 @@ pub(super) fn drive_animations( // ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first — // the pose-snapshot decay of the client's op4 blend, scoped exactly like // [`leave_special`]'s (decision 0503). - if matches!(special, Some(select::Special::Jump | select::Special::Fall)) { + if let Some(sp) = + special.filter(|sp| matches!(sp, select::Special::Jump | select::Special::Fall)) + { + // …the ARC's clip, not merely whatever bone 0 holds ([`holds_own_clip`]) — + // the freeze's missing predicate, at its second site (decision 2098). if let Some(active) = tr .get_main_animation() + .filter(|&n| holds_own_clip(anims, catalog, sp, n)) .and_then(|n| player.animation_mut(n)) { active.set_speed(0.0); @@ -1448,7 +1453,16 @@ pub(super) fn drive_animations( let masked_played = masked_played || hold_played.is_some(); - let base_played = base_played || (drv.mode, drv.gait) != pre_state; + // A mode/gait change is a **proxy** for the mode machine's plays, and the base-anim lock + // is the one state where the proxy is wrong for longer than the single frame its own doc + // allows: while the lock refuses, the machine still walks its brackets — Gait → + // Entering(Jump) → Looping(Jump) → Land — with every play declined, so a change there + // means nothing was armed at all. Claiming otherwise evicted the victim's flinch off the + // key-bone and latched the weapon trail's edge (2076) on a play that never happened + // (decision 2098). Only the PROXY is gated: an arm that reached the player directly says + // so on its own. + let base_played = + base_played || ((drv.mode, drv.gait) != pre_state && !drv.base_lock.refuses()); // The weapon-trail latch's edge (decision 2076) — `0x5fe2f0` is the image's single // animation entry point, so ANY start consumes the arm, the mode machine's gait plays // included. Written every pass (never OR'd) so it is exactly this frame's. diff --git a/crates/benilla-app/src/creature_anim/driver/play.rs b/crates/benilla-app/src/creature_anim/driver/play.rs index 830af7d0..52a7c81f 100644 --- a/crates/benilla-app/src/creature_anim/driver/play.rs +++ b/crates/benilla-app/src/creature_anim/driver/play.rs @@ -251,6 +251,15 @@ pub(super) fn play( rng: &mut u32, window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, ) { + // The lock's guard is `PlayAnimation`'s **front door**, above the arm helper that draws the + // rolls (`0x5fe2f0`'s epilogue return is at `0x5fe3a1`; the `_rand()` sites live inside + // `0x5fdba0`'s op4 call). So a refused play must not roll either: the variation and replay + // draws come off the ONE shared stream (decision 0114), and rolling them for a body that + // cannot move would perturb every other unit's picks for the clip's whole 2 s — the same + // churn 2096 caught in the gait selector's live trace, at the event-play door instead. + if lock.refuses() { + return; + } if let Some(c) = find_resolved(anims, id, catalog) { let (c, repeat) = if looping { let (c, r) = roll_loop(anims, c, relaxed, rng); @@ -342,6 +351,33 @@ pub(super) fn oneshot_finished( } } +/// Whether bone 0 still holds `sp`'s **own** clip — the arc's enter or its loop, resolved through +/// the model's baked fallback exactly as [`play`] resolved it when it armed (decision 0082) and +/// compared on the *resolved* id, so a rolled variation counts as the same clip. +/// +/// This is the predicate the airborne snapshot-freeze (decision 0503, scoped by 1566) never had. +/// The freeze exists to still **the cut airborne clip** before the landing cross-fades over it; +/// both its sites took whatever bone 0 happened to hold, on the unstated assumption that the arc's +/// own clip is what is there. The base-anim lock (2096) is the first thing that ever falsified it, +/// and it falsified it catastrophically: every play the arc asked for was refused, so bone 0 still +/// held the `Knockdown` that took the lock — and the landing stopped **that** dead. A clip stopped +/// dead never finishes, and the lock clears on the finished id, so the body stayed on its back for +/// the rest of the session with every later play refused (the director's report, decision 2098). +pub(super) fn holds_own_clip( + anims: &ModelAnimations, + catalog: Option<&AnimDataCatalog>, + sp: Special, + node: bevy::animation::graph::AnimationNodeIndex, +) -> bool { + let Some(cur) = anims.clips.iter().find(|c| c.node == node) else { + return false; + }; + [sp.enter(), sp.loop_id()] + .into_iter() + .filter_map(|id| find_resolved(anims, id, catalog)) + .any(|head| head.anim_id == cur.anim_id) +} + /// Enter the Special `sp`, returning the mode to adopt. A pose or a jump plays its enter one-shot /// and settles through [`Mode::Entering`]; **Fall has no enter** — the client plays the Fall(40) /// loop directly the tick FALLINGFAR latches (`0x602c40`) — so it goes straight to @@ -430,11 +466,32 @@ pub(super) fn leave_special( // derivation; what produces the reference's lingering kick is not yet known. // …and the frozen node is NAMED (decision 0906), so the per-frame rate write // ([`sync_base_rate`]) skips it instead of restarting the clock a line above just stopped. + // + // …and it is **the arc's own clip** that is stilled, never merely whatever bone 0 holds + // ([`holds_own_clip`]) — the predicate this always meant and never wrote down. if matches!(sp, Special::Jump | Special::Fall) { if let Some(node) = tr.get_main_animation() { - if let Some(active) = player.animation_mut(node) { - active.set_speed(0.0); - *frozen = Some(node); + if holds_own_clip(anims, catalog, sp, node) { + if let Some(active) = player.animation_mut(node) { + active.set_speed(0.0); + *frozen = Some(node); + } + } else if benilla_assets::trace::enabled() { + // The declined freeze is TRACED, because the wedge it used to cause was + // completely silent: nothing logs a clip being stopped, and a stopped clip that + // holds the base-anim lock ends the unit's animation for the session (2098). + let held = anims + .clips + .iter() + .find(|c| c.node == node) + .map(|c| c.anim_id); + benilla_assets::trace::line( + "anim", + &format!( + "anim freeze DECLINED leaving {sp:?}: bone 0 holds {held:?}, not this \ + arc's clip" + ), + ); } } } diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index d83ca1db..742c4af1 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -4321,4 +4321,108 @@ mod base_anim_lock { "already playing it — the leg leaves the block having done nothing" ); } + + /// The airborne clips, on the same victim — the director's follow-up report: *"there is some + /// bug it seems, I ended up stuck laying down after a knock, might be because I was jumping + /// or running at the same time"*. + fn airborne_model() -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: vec![ + clip(0, STAND_NODE, true), // Stand + clip(121, KNOCKDOWN_NODE, false), // Knockdown — takes the lock + clip(37, 3, false), // JumpStart + clip(38, 4, true), // Jump hang + clip(39, 5, false), // JumpEnd + clip(40, 6, true), // Fall + ], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } + + fn knocked_jumper(app: &mut App) -> Entity { + app.world_mut() + .spawn(( + airborne_model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + MovementState::default(), + )) + .id() + } + + /// **The wedge**: jumping while the lock is held used to stop the locked clip DEAD, and a clip + /// that never finishes never releases the lock — so the body lay on its back for the rest of + /// the session, every subsequent play refused. + /// + /// The mechanism is 0503's snapshot-freeze in [`super::play::leave_special`], which stills the + /// cut airborne clip before the landing cross-fades over it. It took whatever bone 0 held, on + /// the assumption that the arc's own clip is what it armed — and the lock is the first thing + /// ever to falsify that: every play the arc asked for was refused, so bone 0 still held the + /// `Knockdown`, and the landing froze *that*. + #[test] + fn a_jump_taken_while_locked_never_freezes_the_locked_clip() { + let mut app = app(); + let unit = knocked_jumper(&mut app); + app.update(); // settle: Stand + oneshot(&mut app, unit, 121); + let knockdown = AnimationNodeIndex::new(KNOCKDOWN_NODE as usize); + assert_eq!( + main_node(&app, unit), + Some(knockdown), + "the impact kit's Knockdown holds the base" + ); + + // Space, flat on their back: the launch enters the bracket, and every clip in the bracket + // is refused — so bone 0 still holds the Knockdown for the whole arc. + app.world_mut().entity_mut(unit).insert(MovementState { + flags: move_flags::FALLING, + vertical_speed: 7.96, + ..Default::default() + }); + advance(&mut app, 16); + assert!( + !app.world() + .entity(unit) + .get::() + .unwrap() + .started_anim, + "the bracket walked with its play declined — nothing started, so nothing downstream may read a play out of the mode change (the flinch's eviction, 2076's trail edge)" + ); + advance(&mut app, 16); + assert_eq!( + main_node(&app, unit), + Some(knockdown), + "the arc's own JumpStart/hang are refused, exactly as the guard orders" + ); + + // Touchdown. + app.world_mut() + .entity_mut(unit) + .insert(MovementState::default()); + advance(&mut app, 16); + + assert_eq!( + app.world().entity(unit).get::().unwrap().frozen, + None, + "the landing's freeze is the AIRBORNE clip's alone — it must never still a clip the \ + arc did not arm" + ); + assert_eq!( + app.world() + .entity(unit) + .get::() + .unwrap() + .animation(knockdown) + .map(bevy::animation::ActiveAnimation::speed), + Some(1.0), + "…so the Knockdown runs on, finishes, and releases the lock" + ); + } } diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index 4199025c..b0b38b8b 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -58,17 +58,21 @@ const PILL_YIELD_GAP: f32 = 4.0; /// quads are reused byte-identical (see [`pill_quads`]). const HUD_REFRESH_SECS: f32 = 0.25; -/// HUD state. The **dev chord + `P`** toggles `visible` (default on — it's a standing dev -/// surface). `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the -/// overlay off for pristine, UI-free screenshots. +/// HUD state. The **dev chord + `P`** (Ctrl+Shift+P) toggles `visible`, and it starts **hidden** +/// — the director's call, 2026-09-08: the pill sits over the game for the whole session and the +/// game is what a dev build is for looking at. It is an instrument you reach for, not furniture. +/// `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off +/// for pristine, UI-free screenshots. /// -/// **`WOW_PERF_HUD=0` starts it hidden**, which is how the HUD gets priced. 1370 records the open -/// gap: every campaign anchor is measured on a binary that is drawing this overlay, at a cost -/// booked as "est 0.4–1.2 ms CPU + unquantified GPU" — an estimate, never a measurement, because -/// nothing could turn the fixture off without also changing the binary. One env var makes it an -/// interleaved A/B on *one* binary instead (`scripts/leg.sh`), so the constant baked into every -/// anchor becomes a number. The meters keep sampling either way: only the drawing stops, which is -/// the half being priced. +/// **`WOW_PERF_HUD=1` starts it shown**, which is how the HUD gets priced — the knob kept its +/// meaning in both spellings when the default flipped, so `0` and unset are both hidden. 1370 +/// records the open gap: every campaign anchor was measured on a binary that draws this overlay, +/// at a cost booked as "est 0.4–1.2 ms CPU + unquantified GPU" — an estimate, never a measurement, +/// because nothing could turn the fixture off without also changing the binary. One env var makes +/// it an interleaved A/B on *one* binary instead (`scripts/leg.sh`), so the constant baked into +/// every anchor becomes a number. The meters keep sampling either way: only the drawing stops, +/// which is the half being priced — and with the default flipped, the *unmeasured* leg is now the +/// one nobody is running. #[derive(Resource)] pub(crate) struct PerfHud { pub(crate) visible: bool, @@ -88,7 +92,7 @@ pub(crate) struct PerfHud { impl Default for PerfHud { fn default() -> Self { Self { - visible: std::env::var("WOW_PERF_HUD").as_deref() != Ok("0"), + visible: std::env::var("WOW_PERF_HUD").as_deref() == Ok("1"), snap: FrameStats::default(), // −∞, so the very first frame refreshes rather than drawing an empty snapshot. snap_at: f32::NEG_INFINITY, diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 93872c96..f6dc3abd 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -198,6 +198,15 @@ pub(crate) fn self_controlled(unit_flags: u32) -> bool { /// takes a GUID fast path and lands on the identical word. pub(crate) const UNIT_FLAG_IN_COMBAT: u32 = 0x0008_0000; +/// `UNIT_FLAG_TAXI_FLIGHT` — the same `UNIT_FIELD_FLAGS` word, **bit 20** (vmangos +/// `UnitDefines.h:511`; the client reads it as `shr reg,0x14; test rl,1` — wow-re counted 20 +/// independent sites of that idiom with no shared gate, `unit-flags-movement-gates.md` §4). +/// +/// Beside its neighbour for the same reason that one is here: two readers today that have nothing +/// to do with each other — `SetStandState`'s own guard #3 and the idle handler's auto-AFK gate — +/// and a bit that is spelled out twice is a bit that eventually drifts. +pub(crate) const UNIT_FLAG_TAXI_FLIGHT: u32 = 0x0010_0000; + /// Ask for a **stand state** — the client's `SetStandState(newState)` (`0x5ed430`: send /// `CMSG_STANDSTATECHANGE` + apply locally through `0x6127b0`), as a message so every path that /// wants a posture funnels through the ONE setter in [`posture`] (the [`crate::creature_anim:: diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 9707a5d9..1c7c7f40 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -14,9 +14,11 @@ use crate::ui_script::UiInput; #[cfg(test)] mod ace_gate_tests; +/// `/afk` and `/dnd` — the two commands' asymmetric law, the optimistic AFK mirror `[0xb6e5cc]`, +/// and the implicit clear every other chat send and every movement press carries (2088). +mod away; /// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) — the /// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on. -mod away; mod broadcast; mod channels; /// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each @@ -31,12 +33,15 @@ pub(crate) mod edit; mod event; mod feed; mod frames; +/// The idle handler — the 5-minute auto-sit / auto-AFK and the 30-minute camp. +mod idle; mod input; /// The language gate — the exemptions and the fluency lookup behind the chat garble (B262). mod language; +/// `LoggingChat`/`LoggingCombat` — the two log files `/chatlog` and `/combatlog` toggle. +mod logging; /// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size /// picks are read from at login and written back at logout. -mod logging; mod settings; #[cfg(test)] mod tests; @@ -62,6 +67,7 @@ impl Plugin for UiChatPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -121,6 +127,10 @@ impl Plugin for UiChatPlugin { // chat source. .add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat)) .add_systems(Update, feed::feed_chat.before(UiInput)) + // The last-input stamp `[0xcf0bc8]`. Deliberately NOT in-world-gated and + // deliberately ahead of the UI pass: the reference stores it on the raw input bus, + // before dispatch, so a keystroke the chat box swallows still counts as input. + .add_systems(Update, idle::stamp_input.before(UiInput)) // A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before // the feed, so the reload frame's first routed line already renders numbered. .add_systems(Update, channels::seed_channels.before(feed::feed_chat)) @@ -140,6 +150,10 @@ impl Plugin for UiChatPlugin { away::reconcile_afk_mirror, // …and the four movement clears, which share the CVar and the mirror. away::movement_clears_afk, + // The idle handler (2092) — one timer, three legs. After the clears, so a + // press that both stamps the clock and drops the flag is settled before the + // timer that would otherwise re-raise it is read. + idle::idle_handler, input::drain_chat_input, // An addon's own line into the wire (decision 1199). AFTER the box's drain // and in the same chain, so a `SendChatMessage` fired from a slash handler diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index c2dccf5a..3a2dc4ff 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -443,6 +443,21 @@ impl ChatLog { .filter(|p| !matches!(p, Pending::Addon { .. })) .count() } + + /// The text of every already-composed line waiting to render, in order — the queue's + /// [`Pending::Event`] entries, which is what everything that pushes a finished sentence + /// ([`Self::push_event`]) lands as. The `Wire`/`Notice`/`Roll` shapes are deliberately absent: + /// those have no text yet, and a caller asserting on a sentence wants only the ones that do. + #[cfg(test)] + pub(crate) fn pending_lines(&self) -> Vec { + self.pending + .iter() + .filter_map(|p| match p { + Pending::Event(e) => Some(e.text.clone()), + _ => None, + }) + .collect() + } } /// The VM's own string table as a lookup — `getglobal(key)`, which is where every sentence this diff --git a/crates/benilla-app/src/ui_chat/idle.rs b/crates/benilla-app/src/ui_chat/idle.rs new file mode 100644 index 00000000..def9e066 --- /dev/null +++ b/crates/benilla-app/src/ui_chat/idle.rs @@ -0,0 +1,779 @@ +//! **The idle handler** — `WorldFrame::Render 0x482ea0`, the one reference function that watches +//! how long it has been since the player last touched anything and does three unrelated-looking +//! things about it (wow-re `object-layer/scratch/standstate-movement-trigger.md` §5.4 and +//! `ui/scratch/afk-dnd-command-law.md` §10/§12). +//! +//! One module because it is **one timer**, read once per frame against one global stamp, with a +//! three-way branch hung off it: +//! +//! | idle | what happens | +//! |---|---| +//! | `< 300 000 ms` | nothing at all — `0x482ed8 jl 0x482fbf` leaves the whole block | +//! | `300 000 – 1 799 999 ms` | **auto-sit** (`0x482f91 call 0x5ed430(1)`), then **auto-AFK** (`0x482fba call 0x5eb740(NULL)`) | +//! | `>= 1 800 000 ms` | print `IDLE_MESSAGE`, then `0x5ab000` — and pointedly **no** sit and **no** AFK | +//! +//! Both thresholds are read off the immediates: `0x482ecd lea ecx,[eax-0x493e0]` is `-300 000`, and +//! `0x482ede add eax,0xffe488c0` is `-1 800 000`. +//! +//! **The 30-minute leg is a CAMP, not a kick.** `0x5ab000` is `ClientServices::SendLogout(flag, +//! force)` (wow-re `system/net/ledger.tsv`) — the *same* function the game menu's Logout button +//! reaches through `Logout 0x489390`, sending `CMSG_LOGOUT_REQUEST 0x4B`. So the server decides: +//! it refuses outright in combat, logs you out instantly in an inn, and otherwise runs the +//! twenty-second countdown that [`crate::ui_logout`] already narrates as the CAMP dialog. A +//! re-implementation that tore the socket down here would be both unfaithful and much ruder than +//! the thing it was imitating. +//! +//! **Nothing here needs a latch of its own, because every leg is gated on the state it is about to +//! create.** The sit is refused once seated, the auto-AFK is gated on the mirror `[0xb6e5cc] == 0` +//! and sets it, and the logout request is swallowed by the dispatcher's own pending byte +//! `[session+0x1b1d]`. That self-limiting shape is the reference's, and it is why a handler that +//! runs every single frame for twenty-five minutes does not spam the wire. +//! +//! The third of those did not exist here until this landed. [`crate::ui_logout`] knew about +//! `[session+0x1b1d]` in prose and never modelled it, because the only caller was a button a human +//! presses once; a caller that asks sixty times a second is what made the gap load-bearing. It is +//! built where the reference has it — in the dispatcher, covering every caller — rather than as a +//! private flag here. + +use std::time::Duration; + +use bevy::input::keyboard::KeyboardInput; +use bevy::input::mouse::{AccumulatedMouseMotion, MouseButtonInput, MouseWheel}; +use bevy::prelude::*; +use bevy::time::Real; + +use super::away::{afk_line, push_system, AfkMirror}; +use super::feed::ChatLog; + +/// **`[0xcf0bc8]` — the last-input millisecond stamp**, the one thing the idle timer subtracts +/// from. +/// +/// Elapsed real time, which is the same quantity both sides of the reference's subtraction come +/// from: the stamp is written by exactly one call (`0x42d7bb`, inside the ring writer `0x42d780`) +/// to the same `0x42c010` the handler reads at `0x482ebe`. `GetMessageTime` is not imported and +/// `MSG.time` is never read — the OS message's own clock never enters this. +/// +/// **Process-global, zero-initialised, and never reset** — including at world enter, which is the +/// obvious-looking thing to do and is wrong. One cell serves the login screen, character select +/// and the world alike, and it starts at zero, so a client whose first five minutes are untouched +/// is idle by the reference's own reckoning. [`stamp_input`] is ungated for the same reason. +#[derive(Resource, Debug, Clone, Copy, Default)] +pub(crate) struct LastInput(Duration); + +/// **5 minutes** — `0x482ecd lea ecx,[eax-0x493e0]`, `0x493e0` = 300 000 ms. +const AUTO_AFK_AFTER: Duration = Duration::from_millis(300_000); + +/// **30 minutes** — `0x482ede add eax,0xffe488c0`, i.e. `eax - 1 800 000`. +/// +/// **Absolute, not five-plus-thirty.** The subtraction above it writes `ecx`, so `eax` still holds +/// the raw elapsed when this one runs. Reading the pair as two chained deductions gives 35 minutes +/// and is the natural misreading of the two-instruction sequence; the §5 round caught itself +/// making it. +const AUTO_LOGOUT_AFTER: Duration = Duration::from_millis(1_800_000); + +/// Stamp the clock — the **seven** writers of `[0xcf0bc8]`, all registered on the one input ring +/// at priority `1.0f` (`0x765c04`–`0x765cfe` via `0x41fca0`), all storing the same ring field +/// `payload[3]` (wow-re `ui/scratch/idle-timer-input-stamp-law.md`, a §5 with four independent +/// reproductions and a closed image-wide census of the dword: 37 occurrences, 7 W / 30 R). +/// +/// | writer | bus category | what the player did | +/// |---|---|---| +/// | `0x765f34` | 8 | a key went **down** — first press only | +/// | `0x765fec` | 9 | a key came **up** | +/// | `0x7662fb` | 0xb | a mouse button went **down** | +/// | `0x766152` | 0xc | the mouse **moved** (absolute, filtered) | +/// | `0x766291` | 0xd | the mouse **moved** (delta) | +/// | `0x766461` | 0xe | a mouse button came **up** | +/// | `0x76651f` | 0x10 | the **wheel** turned | +/// +/// **The keyboard and the mouse follow opposite rules, and there is no single sentence covering +/// both.** A held key stamps once; a moved mouse stamps continuously. What does *not* stamp: +/// +/// - **Auto-repeat.** `0x424810` promotes category 8 to 0xa at `0x4248b3` when the key is already +/// held, and `0x766070` (category 0xa) has no store. So **holding a key is idle** — running with +/// W held, or on autorun, reaches the five-minute auto-AFK. This is the single most surprising +/// thing the round found and it is deliberate here, not an omission. +/// - Zero-motion moves and the pump's synthetic hover re-pick (`0x7660d0`'s `0x766122`–`0x766140` +/// filter), which is the bypass `hover-hide-and-tooltip-owner-law.md` recorded without pinning. +/// - Double-clicks, `WM_MOUSEHWHEEL`, `WM_CHAR`, the joystick, and any VK outside `0x42d800`'s +/// table. +/// - Mouse **movement and left-button-up during an OS window drag** — a title-bar drag or border +/// resize, mirrored from `SetCapture`/`ReleaseCapture` in `[0x884e64]`. We inherit that for free: +/// an OS-driven window drag delivers no motion to the app at all. +/// +/// **Mouselook is NOT an exclusion** — it is `[0x884e5c]`, a different global entirely, and all it +/// picks is which arm enqueues (absolute → category 0xc, delta → category 0xd, both stamping). A +/// player turning the camera with the right button held is *not* idle. This module briefly recorded +/// the opposite, off a mislabelled global, before the second round pinned `[0x884e64]`'s writers to +/// `SetCapture`. +/// +/// **The store sits on the raw input bus, ahead of dispatch**, so a keystroke a frame *consumes* +/// still stamps: typing into the chat edit box is input, and so is a click that only moves a +/// window. That is why this reads the raw messages rather than [`crate::bindings::BindingsState`], +/// which is the post-dispatch view and would let a player who is busy typing go AFK mid-sentence. +/// +/// Runs **outside** the in-world gate the handler itself carries, because the stamp is +/// process-global: character select, the login screen and the world all write the one cell. +pub(crate) fn stamp_input( + time: Res>, + mut last: ResMut, + mut keys: MessageReader, + mut buttons: MessageReader, + mut wheel: MessageReader, + motion: Res, +) { + // `count()` rather than `next().is_some()`, and bitwise `|` rather than `||`: both are about + // draining. A reader that stops at the first message leaves the rest on its cursor and re-sees + // them next frame, and a short-circuited `||` never touches the later queues at all — either + // one keeps the clock warm for frames after the input actually stopped. + // `repeat` is category 0xa, which has no store — a held key stops counting after its first + // press. (`Released` never carries the flag, so this one filter covers both edges.) + let any = (keys.read().filter(|k| !k.repeat).count() != 0) + | (buttons.read().count() != 0) + | (wheel.read().count() != 0) + | (motion.delta != Vec2::ZERO); + if any { + last.0 = time.elapsed(); + } +} + +/// **What the timer decides this frame** — a value rather than three writes, for the reason +/// [`super::away::AwayOutcome`] is one: the whole content of `0x482ea0`'s block is which legs move +/// together and which are mutually exclusive, and that is exactly what a re-implementer gets wrong +/// by treating "idle" as a single state with three effects. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct IdleAction { + /// `0x482f91 call 0x5ed430(1)`. + pub(crate) sit: bool, + /// `0x482fba call 0x5eb740(NULL)`. + pub(crate) afk: bool, + /// `0x482f1a` + `0x5ab000` — `IDLE_MESSAGE` and the logout request. + pub(crate) camp: bool, +} + +/// The state the two five-minute legs are gated on. Read off our own descriptor; each field is one +/// test in the reference's block, and no two of them share a gate. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct IdleGates { + /// `UNIT_FIELD_BYTES_1` byte 0. The sit wants `0`. + pub(crate) stand_state: u8, + /// `UNIT_FIELD_FLAGS` bit 19 (`0x80000`) — the sit's. + pub(crate) in_combat: bool, + /// `UNIT_FIELD_MOUNTDISPLAYID > 0` — the sit's. + pub(crate) mounted: bool, + /// The sit's fourth gate, `0x482f84 call 0x6103a0` / `jne` past the setter — which requires the + /// predicate **false**, and for a solo player reduces to `[0xc4d888] == 0xc`, its normal + /// in-world value. + /// + /// **Exactly one thing benilla has puts it elsewhere.** The global's writer census is closed — + /// 25 dword occurrences, 4 writes, three of them the literal `0xc` — so every non-`0xc` value + /// comes through the one `StartMoveAction 0x611130`, whose eleven kinds are the click-to-move + /// family (4, 8), the move-to-interact family (5, 6, 7, 9, 0xa) and **`3` = `/follow`**. Mode 3 + /// is co-extensive with follow being active, proven twice over: `0x489e00` is `FollowUnit`, and + /// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` (367/368) have exactly one fire site each, the second + /// gated on `[0xc4d888] == 3`. + /// + /// **Autorun never touches the global** — no writer lies in the movement-command band at all; + /// the only traffic runs the other way (`0x5146d6` *calls the cancel*). So this gate is not a + /// stand-in for "moving", and it is not redundant with `stand_state_refused`: a follow whose + /// target has stopped walking is motionless, passes every movement test, and is still the one + /// player the reference will not seat. + pub(crate) following: bool, + /// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`) — the **AFK's**, not the sit's. + pub(crate) on_taxi: bool, + /// The optimistic mirror `[0xb6e5cc]` — the AFK's second gate, and the anti-repeat. + pub(crate) afk: bool, +} + +/// Resolve the timer against the gates — `0x482ea0`'s block, byte for byte. +/// +/// **The two bands are exclusive, and that is the easy thing to get wrong.** `0x482ee5 jl +/// 0x482f39` is what jumps *into* the 5–30 minute block, so reaching thirty minutes takes the +/// other arm entirely: the camp leg neither sits you nor marks you AFK. A client that let the +/// thirty-minute case fall through the five-minute one would sit a player down on the same frame +/// it asked to log them out. +/// +/// **Nothing latches.** Every leg is gated on the state it is about to create — the sit on +/// `stand_state == 0`, the AFK on the mirror it sets, and the camp on the logout request's own +/// pending latch downstream — so a handler that runs every frame for twenty-five minutes emits +/// exactly one of each. +pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction { + if idle < AUTO_AFK_AFTER { + // `0x482ed8 jl 0x482fbf` — the whole block, all three legs, sits below this. + return IdleAction::default(); + } + if idle >= AUTO_LOGOUT_AFTER { + return IdleAction { + camp: true, + ..IdleAction::default() + }; + } + IdleAction { + sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.following, + afk: !gates.on_taxi && !gates.afk, + camp: false, + } +} + +/// The handler proper. In-world only: `0x482ea0` is `WorldFrame::Render`, so there is no idle +/// timer at the glue screens. +#[allow(clippy::too_many_arguments)] +pub(crate) fn idle_handler( + time: Res>, + last: Res, + script: Option>, + self_q: Query<&crate::net::ObjectStore, With>, + mut mirror: ResMut, + mut chat: ResMut, + commands: Res, + mut stand: MessageWriter, + follow: Res, +) { + let idle = time.elapsed().saturating_sub(last.0); + if idle < AUTO_AFK_AFTER { + return; + } + let Some(mut script) = script else { + return; + }; + let gates = self_q.iter().next().map(|store| { + let flags = store.0.unit_flags(); + IdleGates { + stand_state: store.0.unit_stand_state(), + in_combat: flags & crate::player::UNIT_FLAG_IN_COMBAT != 0, + mounted: store.0.unit_mount_display_id() != 0, + on_taxi: flags & crate::player::UNIT_FLAG_TAXI_FLIGHT != 0, + afk: mirror.is_afk(), + following: follow.guid.is_some(), + } + }); + let action = match gates { + Some(g) => idle_action(idle, g), + // No body streamed yet is not "every gate clear": the reference resolves the local player + // (`0x468550` → `0x468460`) inside the block, so the sit and the AFK have nothing to read. + // The camp leg never touches the player object, and survives. + None => IdleAction { + camp: idle >= AUTO_LOGOUT_AFTER, + ..IdleAction::default() + }, + }; + + if action.sit { + // Through the ONE setter, like the reference's own call: `player::posture` runs + // `stand_state_refused` on the way (so an idle *swimmer* is not seated, bug B155) and + // drops a request equal to the state it already holds, consulting its own `stand_pending` + // rather than the descriptor — which is what makes re-asking every frame across the server + // round trip produce exactly one `CMSG_STANDSTATECHANGE`. + stand.write(crate::player::StandStateRequest { state: 1 }); + } + + let strings = |key: &str| crate::ui_chat::combat::global_string(&script, key); + if action.afk { + // `SetAFK(NULL)` is `/afk` with an empty message and the mirror clear — the one row of the + // §12 truth table this can reach, which is why it goes through the same function rather + // than composing its own line: same `MARKED_AFK_MESSAGE % DEFAULT_AFK_MESSAGE` echo, the + // same client-side default substitution reaching the wire, the same optimistic mirror. + let out = afk_line("", *mirror, &strings); + if let Some(line) = out.line { + push_system(&mut chat, line); + } + if let Some(v) = out.mirror { + mirror.0 = v; + } + let _ = commands.0.send(crate::net::ClientCommand::Chat { + kind: crate::net::ChatKind::Afk, + target: None, + text: out.body, + }); + } + if action.camp { + if let Some(line) = strings("IDLE_MESSAGE") { + push_system(&mut chat, line); + } + // `0x5ab000` with the game menu's own arguments: `CMSG_LOGOUT_REQUEST`, the server's + // clock, the CAMP dialog. `crate::ui_logout` owns everything from here. + script.queue_session_request(benilla_ui::script::SessionRequest::Logout); + } +} + +#[cfg(test)] +mod tests { + use benilla_protocol::ObjectFields; + use bevy::ecs::system::RunSystemOnce; + + use super::*; + use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; + + /// Descriptor indices, spelled here so a fixture reads like the field it sets. + const UNIT_FLAGS: u16 = 46; + const MOUNT_DISPLAY_ID: u16 = 133; + const BYTES_1: u16 = 138; + + const FIVE_MIN: Duration = Duration::from_millis(300_000); + const THIRTY_MIN: Duration = Duration::from_millis(1_800_000); + + /// A body that is standing, out of combat, unmounted, off a taxi and not yet AFK — every gate + /// open, so a test that flips one field is testing exactly that field. + fn open() -> IdleGates { + IdleGates::default() + } + + /// **The two thresholds, at the millisecond.** `0x493e0` = 300 000 and `0xffe488c0` = + /// −1 800 000, both read off the immediates rather than off "5" and "30". + #[test] + fn the_thresholds_are_the_immediates_not_round_numbers() { + let one_ms = Duration::from_millis(1); + assert_eq!( + idle_action(FIVE_MIN - one_ms, open()), + IdleAction::default(), + "4:59.999 does nothing — `0x482ed8 jl 0x482fbf` leaves the whole block" + ); + assert_eq!( + idle_action(FIVE_MIN, open()), + IdleAction { + sit: true, + afk: true, + camp: false + }, + "300 000 ms exactly is INSIDE the band: the jcc is `jl`, not `jle`" + ); + assert_eq!( + idle_action(THIRTY_MIN - one_ms, open()), + IdleAction { + sit: true, + afk: true, + camp: false + }, + ); + assert_eq!( + idle_action(THIRTY_MIN, open()), + IdleAction { + camp: true, + ..IdleAction::default() + }, + "1 800 000 ms exactly takes the camp arm, again on `jl`" + ); + } + + /// **The bands are exclusive** — the thirty-minute leg neither sits nor marks, because + /// `0x482ee5 jl 0x482f39` is what jumps *into* the five-minute block. A fall-through would + /// seat a player on the frame it asked to log them out. + #[test] + fn the_camp_leg_does_not_also_sit_or_mark_afk() { + for idle in [THIRTY_MIN, THIRTY_MIN + Duration::from_secs(3600)] { + assert_eq!( + idle_action(idle, open()), + IdleAction { + camp: true, + sit: false, + afk: false + }, + ); + } + } + + /// **Each gate refuses its own leg and only its own.** The sit's three and the AFK's two are + /// different tests at different addresses on different fields; the one thing a re-implementer + /// is tempted to do is fold them into a single "can I go idle" predicate. + #[test] + fn the_gates_are_per_leg_never_shared() { + let cases: [(&str, IdleGates, IdleAction); 7] = [ + ( + "in combat: no sit — but the AFK mark is NOT gated on combat", + IdleGates { + in_combat: true, + ..open() + }, + IdleAction { + sit: false, + afk: true, + camp: false, + }, + ), + ( + "mounted: no sit, same asymmetry", + IdleGates { + mounted: true, + ..open() + }, + IdleAction { + sit: false, + afk: true, + camp: false, + }, + ), + ( + "already seated: nothing to ask for, and the AFK is untouched", + IdleGates { + stand_state: 1, + ..open() + }, + IdleAction { + sit: false, + afk: true, + camp: false, + }, + ), + ( + "on a taxi: no AFK — and the sit is NOT gated on the taxi bit here (its own \ + `0x5ed430` guard #3 is what covers that downstream)", + IdleGates { + on_taxi: true, + ..open() + }, + IdleAction { + sit: true, + afk: false, + camp: false, + }, + ), + ( + "following someone who has stopped walking: no sit — this is the ONLY case the \ + fourth gate does work nothing else does, since anything else that leaves \ + `[0xc4d888]` non-`0xc` implies translating, which `stand_state_refused` refuses \ + downstream. The AFK mark is untouched: it is not one of the sit's gates", + IdleGates { + following: true, + ..open() + }, + IdleAction { + sit: false, + afk: true, + camp: false, + }, + ), + ( + "already AFK: the anti-repeat, and it does not suppress the sit", + IdleGates { + afk: true, + ..open() + }, + IdleAction { + sit: true, + afk: false, + camp: false, + }, + ), + ( + "seated AND already AFK — the steady state five minutes in: nothing more happens, \ + every frame, for the next twenty-five minutes", + IdleGates { + stand_state: 1, + afk: true, + ..open() + }, + IdleAction::default(), + ), + ]; + for (why, gates, want) in cases { + assert_eq!(idle_action(FIVE_MIN, gates), want, "{why}"); + } + } + + /// The steady state is what makes the missing latch safe: run the decision a thousand times + /// with the state each leg creates fed back in, and exactly one sit and one mark come out. + #[test] + fn the_handler_is_self_limiting_without_any_latch() { + let mut gates = open(); + let (mut sits, mut marks) = (0, 0); + for frame in 0..1000 { + let a = idle_action(FIVE_MIN + Duration::from_millis(frame), gates); + if a.sit { + sits += 1; + gates.stand_state = 1; // the server's echo of our own CMSG_STANDSTATECHANGE + } + if a.afk { + marks += 1; + gates.afk = true; // `SetAFK` writes the mirror `[0xb6e5cc]` it is gated on + } + } + assert_eq!((sits, marks), (1, 1)); + } + + /// **The seven writers stamp; auto-repeat does not.** The reference's store sits on the raw + /// message bus ahead of dispatch, so this reads the raw Bevy messages rather than + /// [`crate::bindings::BindingsState`] — a keystroke the chat edit box swallows is still a + /// keystroke, and a player mid-sentence must not be marked away. + #[test] + fn every_writer_stamps_and_auto_repeat_pointedly_does_not() { + use bevy::input::keyboard::{Key, KeyboardInput}; + use bevy::input::mouse::{MouseButtonInput, MouseScrollUnit}; + use bevy::input::ButtonState; + + let stamped = |seed: &dyn Fn(&mut App)| { + let mut app = App::new(); + app.init_resource::>() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::(); + app.world_mut() + .resource_mut::>() + .advance_by(Duration::from_millis(1234)); + seed(&mut app); + app.world_mut().run_system_once(stamp_input).unwrap(); + app.world().resource::().0 + }; + + let idle = stamped(&|_| {}); + assert_eq!( + idle, + Duration::ZERO, + "a frame with no input leaves it alone" + ); + + let touched = Duration::from_millis(1234); + assert_eq!( + stamped(&|app| { + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::KeyW, + logical_key: Key::Character("w".into()), + state: ButtonState::Pressed, + text: None, + repeat: false, + window: Entity::PLACEHOLDER, + }); + }), + touched, + "a key press" + ); + assert_eq!( + stamped(&|app| { + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::KeyW, + logical_key: Key::Character("w".into()), + state: ButtonState::Released, + text: None, + repeat: false, + window: Entity::PLACEHOLDER, + }); + }), + touched, + "and a key RELEASE — letting go of a key is input too" + ); + assert_eq!( + stamped(&|app| { + app.world_mut().write_message(MouseButtonInput { + button: MouseButton::Left, + state: ButtonState::Pressed, + window: Entity::PLACEHOLDER, + }); + }), + touched, + "a mouse button" + ); + assert_eq!( + stamped(&|app| { + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + }), + touched, + "the wheel" + ); + assert_eq!( + stamped(&|app| { + app.world_mut() + .resource_mut::() + .delta = Vec2::new(0.0, 1.0); + }), + touched, + "and bare mouse movement, with no button held" + ); + + // **The one that is not an omission.** `0x424810` promotes a held key from bus category 8 + // to 0xa, and `0x766070` — category 0xa's handler — has no store. So running with W held, + // or sitting on autorun, walks straight into the five-minute auto-AFK. It is the single + // most surprising thing the §5 found, four workers agreed on it, and a re-implementation + // that "fixed" it would diverge from the reference in a way a player would notice. + assert_eq!( + stamped(&|app| { + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::KeyW, + logical_key: Key::Character("w".into()), + state: ButtonState::Pressed, + text: None, + repeat: true, + window: Entity::PLACEHOLDER, + }); + }), + Duration::ZERO, + "an auto-repeat is category 0xa, which has no store — holding a key is IDLE" + ); + } + + /// A world with a local player, a live chat log and a wire we can read back. + fn world( + idle_ms: u64, + fields: &[(u16, u32)], + ) -> (App, crossbeam_channel::Receiver) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .insert_resource(NetCommands(tx)); + // `LastInput` stays at zero and the clock walks forward, so "idle" is just the elapsed. + app.world_mut() + .resource_mut::>() + .advance_by(Duration::from_millis(idle_ms)); + app.world_mut() + .spawn((SelfPlayer, ObjectStore(ObjectFields::from_pairs(fields)))); + + let script = benilla_ui::script::UiScript::new().unwrap(); + // The three GlobalStrings this handler reaches for, exactly as the install ships them. + script + .run( + "MARKED_AFK_MESSAGE = \"You are now AFK: %s\"\n\ + DEFAULT_AFK_MESSAGE = \"Away from Keyboard\"\n\ + IDLE_MESSAGE = \"You have been inactive for some time and will be logged \ + out of the game. If you wish to remain logged in, hit the cancel \ + button.\"", + ) + .unwrap(); + app.insert_non_send_resource(script); + (app, rx) + } + + fn lines(app: &App) -> Vec { + app.world().resource::().pending_lines() + } + + /// **The whole five-minute arm, through the real systems.** The sit is a + /// [`crate::player::StandStateRequest`] (so it inherits `stand_state_refused`), the mark is the + /// `/afk` line and the `/afk` packet, and the mirror leads the descriptor exactly as a typed + /// `/afk` would. + #[test] + fn five_minutes_idle_sits_you_down_and_marks_you_afk() { + let (mut app, rx) = world(300_000, &[]); + app.world_mut().run_system_once(idle_handler).unwrap(); + + assert_eq!(lines(&app), vec!["You are now AFK: Away from Keyboard"]); + assert!(app.world().resource::().is_afk()); + // The CLIENT's default substitution reaches the wire, never an empty body — the server + // stores this string as the character's auto-reply. + let sent = rx.try_iter().collect::>(); + assert!( + matches!( + sent.as_slice(), + [ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text }] + if text == "Away from Keyboard" + ), + "{sent:?}" + ); + let sat = app + .world_mut() + .resource_mut::>() + .drain() + .map(|r| r.state) + .collect::>(); + assert_eq!(sat, vec![1], "one sit, through the one setter"); + } + + /// Four minutes fifty-nine is not idle. Nothing at all — no line, no packet, no posture. + #[test] + fn just_under_five_minutes_is_completely_silent() { + let (mut app, rx) = world(299_999, &[]); + app.world_mut().run_system_once(idle_handler).unwrap(); + assert!(lines(&app).is_empty()); + assert!(!app.world().resource::().is_afk()); + assert!(rx.try_iter().next().is_none()); + } + + /// **The thirty-minute arm is a CAMP, and it is not an AFK.** `0x5ab000` is + /// `ClientServices::SendLogout`, the same function the game menu's Logout button reaches, so + /// what comes out here is a [`benilla_ui::script::SessionRequest::Logout`] and nothing else: + /// no `/afk` line, no `/afk` packet, no sit. + #[test] + fn thirty_minutes_idle_camps_instead_of_marking_afk() { + let (mut app, rx) = world(1_800_000, &[]); + app.world_mut().run_system_once(idle_handler).unwrap(); + + assert_eq!( + lines(&app), + vec![ + "You have been inactive for some time and will be logged out of the game. If you \ + wish to remain logged in, hit the cancel button." + ], + "IDLE_MESSAGE goes out unformatted — `0x482f1a` hands `GetText`'s result straight to \ + the chat sink, with no snprintf in between. It carries no format slot, and the \ + sentence it does carry (\"hit the cancel button\") is the CAMP dialog's, which is \ + the string's own confirmation that this leg is a logout REQUEST and not a kick." + ); + assert!( + !app.world().resource::().is_afk(), + "the camp leg pointedly does NOT mark AFK" + ); + assert!(rx.try_iter().next().is_none(), "and sends no chat packet"); + assert!( + app.world_mut() + .non_send_resource_mut::() + .take_session_requests() + == vec![benilla_ui::script::SessionRequest::Logout], + ); + assert!(app + .world_mut() + .resource_mut::>() + .drain() + .next() + .is_none()); + } + + /// The descriptor is what the gates read — a body in combat is not seated, and one on a taxi + /// is not marked. Driven through the real system so the field decode is under test too. + #[test] + fn the_descriptor_bits_reach_the_gates() { + // In combat (`UNIT_FIELD_FLAGS` bit 19): marked, not seated. + let (mut app, _rx) = world(300_000, &[(UNIT_FLAGS, 0x0008_0000)]); + app.world_mut().run_system_once(idle_handler).unwrap(); + assert!(app.world().resource::().is_afk()); + assert!(app + .world_mut() + .resource_mut::>() + .drain() + .next() + .is_none()); + + // On a taxi (bit 20): seated, not marked. + let (mut app, rx) = world(300_000, &[(UNIT_FLAGS, 0x0010_0000)]); + app.world_mut().run_system_once(idle_handler).unwrap(); + assert!(!app.world().resource::().is_afk()); + assert!(rx.try_iter().next().is_none()); + assert_eq!( + app.world_mut() + .resource_mut::>() + .drain() + .count(), + 1 + ); + + // Mounted: marked, not seated. + let (mut app, _rx) = world(300_000, &[(MOUNT_DISPLAY_ID, 6080)]); + app.world_mut().run_system_once(idle_handler).unwrap(); + assert!(app.world().resource::().is_afk()); + assert!(app + .world_mut() + .resource_mut::>() + .drain() + .next() + .is_none()); + + // Already sitting (`UNIT_FIELD_BYTES_1` byte 0 = 1): marked, no second ask. + let (mut app, _rx) = world(300_000, &[(BYTES_1, 1)]); + app.world_mut().run_system_once(idle_handler).unwrap(); + assert!(app.world().resource::().is_afk()); + assert!(app + .world_mut() + .resource_mut::>() + .drain() + .next() + .is_none()); + } +} diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs index 55a9565c..a9eede50 100644 --- a/crates/benilla-app/src/ui_logout.rs +++ b/crates/benilla-app/src/ui_logout.rs @@ -71,14 +71,26 @@ enum LogoutSignal { /// The pending session-exit, and the signals owed to the UI. /// -/// `quitting` is the ONE piece of local state the whole arc needs: the wire cannot tell a logout +/// `quitting` is the piece of local state the arc's *meaning* needs: the wire cannot tell a logout /// from a quit (both are `CMSG_LOGOUT_REQUEST`), so which dialog to show and whether to exit the /// process at the end is remembered here. It is cleared by every terminal edge — a refusal, a /// cancel, or the exit itself — so a cancelled quit can never leave a client that dies on the next /// unrelated logout. +/// +/// `pending` is the reference's own **`[session+0x1b1d]`** (decision 2092), which this module knew +/// about in prose — `apply_cancelled`'s doc has always called it "the same pending byte the +/// refusal arm clears" — and never modelled. `0x5ab000` bails on it *silently* when `force == 0`, +/// so a second request while one is in flight is not a second packet. That gate was invisible +/// while the only caller was a button a human presses once; the idle handler (2092) asks every +/// frame past thirty minutes, and without the latch it would put a `CMSG_LOGOUT_REQUEST` on the +/// wire sixty times a second until something else stamped the idle clock. #[derive(Resource, Default)] pub(crate) struct LogoutState { quitting: bool, + /// `[session+0x1b1d]` — a logout request is out and unanswered. Set by the non-forced path, + /// bypassed *and not set* by `ForceLogout` (`0x5aaff0`, decision 1963), cleared by every + /// terminal edge. + pending: bool, signals: Vec, } @@ -92,6 +104,9 @@ impl LogoutState { info!("logout: server answered reason={reason} instant={instant}"); if reason != 0 { self.quitting = false; + // `0x5aaf21`'s arm: `DisplayError(ERR_LOGOUT_FAILED)` **and clear the pending flag**, + // which is what lets the player try again once they leave combat. + self.pending = false; self.signals.push(LogoutSignal::Refused); return; } @@ -110,6 +125,7 @@ impl LogoutState { /// `LOGOUT_CANCEL` off the same pending byte the refusal arm clears (decision 1821). pub(crate) fn apply_cancelled(&mut self) { self.quitting = false; + self.pending = false; self.signals.push(LogoutSignal::Cancelled); } } @@ -156,6 +172,12 @@ fn drain_logout( for request in script.take_session_requests() { match request { SessionRequest::Logout | SessionRequest::Quit => { + // `0x5ab000`'s pending-logout bail, and it is **silent** — no error line, no + // event, nothing on the wire. It sits ABOVE `0x5ab053`'s store of the quit byte, + // so a refused request does not even get to change camp-into-quit. + if logout.pending { + continue; + } logout.quitting = request == SessionRequest::Quit; // Not in the world (or the socket is gone): there is nothing to log out OF, so a // quit is just an exit and a logout is a no-op. The reference does the same — its @@ -169,6 +191,7 @@ fn drain_logout( logout.quitting = false; } } else { + logout.pending = true; // Announced because what happens next is entirely the server's call — instant // or a 20 s countdown — and a silent request looks identical to a dropped one. info!( @@ -183,6 +206,7 @@ fn drain_logout( // harmless (vmangos ignores it). `quitting` clears here rather than on the ack so a // cancel with a dead socket still can't leave a quit armed. logout.quitting = false; + logout.pending = false; let _ = commands.0.send(ClientCommand::LogoutCancel); } SessionRequest::ForceQuit => { @@ -191,6 +215,9 @@ fn drain_logout( } // `CMSG_PLAYER_LOGOUT`, the forced flavour: the dispatcher's own gate is a live // in-world session, and nothing happens without one (decision 1963). + // `0x5aaff0` calls the dispatcher with `force = 1`, which BYPASSES the pending bail + // and does NOT set the latch (wow-re `staticpopup-dialog-bindings.md` §4) — so a + // forced logout is exactly the escape hatch from a stuck pending one. SessionRequest::ForceLogout => { if self_guid.0.is_some() { info!("logout: forced"); @@ -220,6 +247,9 @@ fn exit_on_logout_complete( if logged_out.read().count() == 0 { return; } + // The request is answered — and on a logout that lands us at character select this is the edge + // that must disarm it, or the next character's first Logout would be swallowed. + logout.pending = false; if logout.quitting { logout.quitting = false; info!("logout: world session ended — exiting"); @@ -244,6 +274,8 @@ impl Plugin for UiLogoutPlugin { #[cfg(test)] mod tests { + use bevy::ecs::system::RunSystemOnce; + use super::*; /// The response table (module docs): refusal speaks and disarms, instant is silent, and the @@ -259,6 +291,7 @@ mod tests { let mut s = LogoutState { quitting: true, + pending: true, signals: Vec::new(), }; s.apply_response(0, false); @@ -268,11 +301,74 @@ mod tests { // minutes later and deliberate, would silently kill the process instead. let mut s = LogoutState { quitting: true, + pending: true, signals: Vec::new(), }; s.apply_response(1, false); assert_eq!(s.signals, vec![LogoutSignal::Refused]); assert!(!s.quitting); + assert!( + !s.pending, + "the refusal arm clears the pending byte — otherwise a logout refused in combat \ + would never be retryable" + ); + } + + /// **The pending latch, both directions** (decision 2092) — `0x5ab000` bails silently on + /// `[session+0x1b1d]`, and every terminal edge clears it. Driven through the real + /// [`drain_logout`] rather than the state alone, because the bail lives in the drain and the + /// thing under test is that the second ask puts nothing on the wire. + #[test] + fn a_second_logout_while_one_is_pending_is_silent() { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .insert_resource(SelfGuid(Some(0x4000_0000_0000_0009))) + .insert_resource(NetCommands(tx)); + app.insert_non_send_resource(UiScript::new().unwrap()); + + let ask = |app: &mut App, request| { + app.world_mut() + .non_send_resource_mut::() + .queue_session_request(request); + app.world_mut() + .run_system_once(drain_logout) + .expect("drain"); + }; + let sent = |rx: &crossbeam_channel::Receiver| rx.try_iter().count(); + + ask(&mut app, SessionRequest::Logout); + assert_eq!(sent(&rx), 1, "the first ask goes out"); + assert!(app.world().resource::().pending); + + // The idle handler's shape: ask again, and again, while the server has not answered. + for _ in 0..60 { + ask(&mut app, SessionRequest::Logout); + } + assert_eq!(sent(&rx), 0, "and not one more packet, nor any error line"); + + // `ForceLogout` bypasses the bail and does not set the latch (`0x5aaff0`, force = 1). + ask(&mut app, SessionRequest::ForceLogout); + assert_eq!(sent(&rx), 1, "the forced flavour is the escape hatch"); + + // A cancel disarms, and the next ask is heard again. + ask(&mut app, SessionRequest::CancelLogout); + assert_eq!(sent(&rx), 1, "CMSG_LOGOUT_CANCEL"); + assert!(!app.world().resource::().pending); + ask(&mut app, SessionRequest::Logout); + assert_eq!(sent(&rx), 1); + + // …and so does the completion, so the next character's first Logout is not swallowed. + app.world_mut().resource_mut::().pending = true; + app.world_mut().write_message(LoggedOutMessage); + app.world_mut() + .run_system_once(exit_on_logout_complete) + .expect("complete"); + assert!(!app.world().resource::().pending); } /// The cancel ack disarms a quit too — the same "no armed quit survives a terminal edge" rule. @@ -280,6 +376,7 @@ mod tests { fn a_cancel_disarms_a_pending_quit() { let mut s = LogoutState { quitting: true, + pending: true, signals: Vec::new(), }; s.apply_cancelled(); diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index f28dcabd..cb8ebe98 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -345,10 +345,12 @@ fn read_under(root: &Path, rel: &str) -> Option> { /// allowed to compute that path (0954) — which is already `None` under `$WOW_CAPTURE`, so a /// deterministic baseline cannot depend on what somebody has installed (0008). /// -/// `pub(crate)` since 1322: the texture side needs the same folder — the sprite decoder's -/// `Interface\AddOns\` loose-file resolve and the `SetTexture` probe both map that virtual prefix -/// onto this root (`ui_script::lifecycle::install_texture_resolvers`), so there is exactly one -/// answer to "where do addons live" however the question is asked. +/// `pub(crate)` since 1322: every asset an addon ships needs the same folder — the sprite +/// decoder's `Interface\AddOns\` loose-file resolve, the `SetTexture` probe, and (2103) the font +/// engine's face loader plus the `SetFont` probe all map that virtual prefix onto this root, so +/// there is exactly one answer to "where do addons live" however the question is asked. The first +/// three are wired in one place (`ui_script::lifecycle::install_addon_asset_resolvers`); the face +/// loader reads it here directly, at engine construction, because it is not a VM concern. pub(crate) fn root() -> Option { root_from(crate::local_state::home()) } @@ -2039,6 +2041,131 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } + /// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names + /// an addon the STARTUP walk already loaded. + /// + /// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text + /// ships its options as a separate `## LoadOnDemand: 1` package with + /// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through + /// `UIParentLoadAddOn`, whose failure message is the red + /// `Couldn't load %s: ` dialog. The dependency being **already loaded** is the + /// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is + /// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands + /// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for + /// exactly that reason until 2102 gave it the same stamp.) + #[test] + fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() { + let _l = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let (home, _h) = hermetic_root("lod-dep"); + write_addon( + &home, + "Base", + "## Interface: 11200\n## Title: Base\nbase.lua\n", + &[("base.lua", "BaseRan = true")], + ); + write_addon( + &home, + "BaseOptions", + "## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n", + &[("opts.lua", "OptsRan = BaseRan")], + ); + let mut script = UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + let _ = load_third_party(&mut script, None, true); + + // The dependency loaded at startup and the registry knows it — the stamp this rests on. + assert_eq!( + script.eval::("return IsAddOnLoaded('Base') == 1").ok(), + Some(true), + "a startup-loaded addon must read as loaded, or every LoadOnDemand dependent of it answers DEP_NOT_DEMAND_LOADED" + ); + assert_eq!( + script.eval::("return OptsRan == nil").ok(), + Some(true), + "and the LoadOnDemand package has not run" + ); + + // `UIParentLoadAddOn`'s own two lines, in order: load, then use what it created. + assert_eq!( + script + .eval::>( + "local loaded, reason = LoadAddOn('BaseOptions') \ + return { tostring(loaded), tostring(reason), tostring(OptsRan), \ + tostring(IsAddOnLoaded('BaseOptions')) }" + ) + .ok(), + Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), + ); + let _ = std::fs::remove_dir_all(home.parent().unwrap()); + } + + /// **A `.toc` line naming a file the package does not ship does not stop a demand load** + /// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk. + /// + /// The reference logs `Couldn't open %s` and carries on (wow-re + /// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel + /// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale: + /// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an + /// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session + /// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus. + #[test] + fn a_missing_manifest_file_does_not_fail_a_demand_load() { + let _l = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let (home, _h) = hermetic_root("lod-missing-file"); + write_addon( + &home, + "Holey", + "## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\ + Locale-koKR.lua\nreal.lua\n", + // `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly. + &[("real.lua", "HoleyRan = true")], + ); + let mut script = UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + let _ = load_third_party(&mut script, None, true); + + assert_eq!( + script + .eval::>( + "local loaded, reason = LoadAddOn('Holey') \ + return { tostring(loaded), tostring(reason), tostring(HoleyRan), \ + tostring(IsAddOnLoaded('Holey')) }" + ) + .ok(), + Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), + "the addon loads, its remaining files run, and it reads as loaded" + ); + // The miss is a WARNING on the host channel, never a script error — the harness's session + // column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole + // point is that a player's broken package cannot redden either. + assert!( + script.take_errors().is_empty(), + "a missing manifest entry must not reach the script-error channel" + ); + let warnings = script.take_warnings(); + assert!( + warnings + .iter() + .any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")), + "…but it must still be said out loud: {warnings:?}" + ); + // …and retained where the player can read it (1495), as the walk's misses are. + assert!( + script + .diagnostics() + .iter() + .any(|d| d.message.contains("Holey/Locale-koKR.lua")), + "the miss is retained in the diagnostic log" + ); + let _ = std::fs::remove_dir_all(home.parent().unwrap()); + } + /// The reason tokens are the reference's own, for the cases a caller branches on. /// /// They are not free-form strings: the reference splices them into diff --git a/crates/benilla-app/src/ui_script/delete_item_tests.rs b/crates/benilla-app/src/ui_script/delete_item_tests.rs index 3af74e76..fca90ec3 100644 --- a/crates/benilla-app/src/ui_script/delete_item_tests.rs +++ b/crates/benilla-app/src/ui_script/delete_item_tests.rs @@ -203,10 +203,13 @@ fn delete_item_confirm_no_clears_without_destroying() { ); // Count repaints from here — the No-click path must trigger one via the event, not a click. + // 5.0's vararg spelling: the implicit `arg` table forwarded with `unpack`, because `...` as a + // VALUE is not in this VM's grammar — it is not in the 1.12 client's (decision 2101). s.run( "repaints = 0\n\ local real = ContainerFrame_Update\n\ - ContainerFrame_Update = function(...) repaints = repaints + 1; return real(...) end", + ContainerFrame_Update = function(...) repaints = repaints + 1; \ + return real(unpack(arg, 1, arg.n)) end", ) .unwrap(); diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 9d3a89fd..6d880684 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -56,7 +56,7 @@ fn install_boot_vm(world: &mut World) { return; } }; - install_texture_resolvers(world, &mut script); + install_addon_asset_resolvers(world, &mut script); load_global_strings(world, &script); load_emote_tokens(world, &script); if ui_wanted(world) { @@ -67,13 +67,16 @@ fn install_boot_vm(world: &mut World) { world.insert_non_send_resource(script); } -/// Wire up the halves of `Interface\AddOns\` texture art (decision 1322): the sprite decoder's -/// **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's -/// [`benilla_assets::WorldAssets::set_loose_sprite_root`]) and the VM's **texture probe** (so the +/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite +/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's +/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the /// path form of `SetTexture` can answer the reference's 1|nil load verdict — Atlas picks its map -/// art by that return). Both resolve the same one folder ([`addons::root`], hermetic-`None` under -/// `$WOW_CAPTURE`), and the probe walks the same [`benilla_assets::sprite_candidates`] the -/// renderer decodes with, so the verdict the Lua caller gets is the verdict the screen shows. +/// art by that return), and the VM's **font probe** (the same 1|nil for `SetFont`, whose nil is a +/// load failure — `!OmniCC` uses it as a font-file validity check). All resolve the same one folder +/// ([`addons::root`], hermetic-`None` under `$WOW_CAPTURE`), and each probe walks exactly the +/// store its renderer reads — [`benilla_assets::sprite_candidates`] for art, the chain-then-folder +/// pair [`crate::ui_text`]'s face loader uses for fonts — so the verdict the Lua caller gets is the +/// verdict the screen shows. /// /// The **size probe** beside it is the same oracle answering a different question — how many texels /// wide and tall is the art — which is what lets a region that authored no size on an axis take @@ -82,23 +85,30 @@ fn install_boot_vm(world: &mut World) { /// is the number the screen shows, and memoises per texture key: the ask is per zero-size region /// per resolve, and the answer cannot change for a key that already read. /// -/// No `WorldAssets` (no client data) means no backend: nothing to install, and the VM's path form -/// keeps answering nil — the engine-less truth. -fn install_texture_resolvers(world: &mut World, script: &mut UiScript) { +/// No `WorldAssets` (no client data) means no backend: nothing to install, so `SetTexture`'s path +/// form keeps answering nil and `SetFont`'s keeps answering 1 — each store's own engine-less truth +/// (`Model::texture_probe` / `Model::font_probe` carry why the two defaults differ). +/// +/// Both probes test **existence**, not decode success: a file that is there but will not decode +/// answers 1 and draws nothing. That is 1322's stated approximation and it is unchanged here; the +/// renderer's own `texture miss` / `font miss` WARN is what names such a file. +fn install_addon_asset_resolvers(world: &mut World, script: &mut UiScript) { let root = addons::root(); let Some(mut assets) = world.get_resource_mut::() else { return; }; - assets.set_loose_sprite_root(root.clone()); + assets.set_loose_addon_root(root.clone()); let chain = assets.chain.clone(); let size_chain = chain.clone(); let size_root = root.clone(); + let font_chain = chain.clone(); + let font_root = root.clone(); script.set_texture_probe(Box::new(move |path| { benilla_assets::sprite_candidates(path).iter().any(|c| { chain.lock_recover().contains(c) || root .as_deref() - .is_some_and(|r| benilla_assets::loose_sprite_file(r, c).is_some()) + .is_some_and(|r| benilla_assets::loose_addon_file(r, c).is_some()) }) })); // Keyed by the reference string exactly as the region carries it, so the hit path allocates @@ -116,6 +126,24 @@ fn install_texture_resolvers(world: &mut World, script: &mut UiScript) { sizes.borrow_mut().insert(path.to_string(), measured); measured })); + // The font oracle. Existence over the same two stores, in the same order, that + // `ui_text::engine`'s face loader reads — a `SetFont` that answers 1 and then draws Friz + // because the two disagreed would be worse than no probe at all. Memoised for the same reason + // the size probe is: an addon can call `SetFont` per animated string per frame (MSBT does), + // and a path's answer cannot change mid-session. + let seen: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); + script.set_font_probe(Box::new(move |path| { + if let Some(&cached) = seen.borrow().get(path) { + return cached; + } + let found = font_chain.lock_recover().contains(path) + || font_root.as_deref().is_some_and(|r| { + benilla_assets::loose_addon_file(r, &benilla_assets::normalize_path(path)).is_some() + }); + seen.borrow_mut().insert(path.to_string(), found); + found + })); } /// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index b4658c5a..d5b6724c 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -1206,14 +1206,15 @@ fn the_shipped_setter_passes_exactly_four_arguments() { load_xml(&s, "OptionsFrame.xml"); load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml"); - // `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string. + // 5.0's `arg.n`, not `select("#", ...)`: `...` as a value is not in this VM's grammar, because + // it is not in the 1.12 client's (decision 2101). s.run( - r##" + r#" BENILLA_TEST_TOGGLE_ARGC = nil function SetActionBarToggles(...) - BENILLA_TEST_TOGGLE_ARGC = select("#", ...) + BENILLA_TEST_TOGGLE_ARGC = arg.n end - "##, + "#, ) .unwrap(); // The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is diff --git a/crates/benilla-app/src/ui_text/engine/mod.rs b/crates/benilla-app/src/ui_text/engine/mod.rs index c49764ba..b8c6ad57 100644 --- a/crates/benilla-app/src/ui_text/engine/mod.rs +++ b/crates/benilla-app/src/ui_text/engine/mod.rs @@ -205,13 +205,33 @@ struct CacheStats { // The engine // ───────────────────────────────────────────────────────────────────────────────────────────── +/// The two stores a font path can come from — the same pair, in the same order, that +/// [`benilla_assets::WorldAssets`]'s sprite decoder walks (decision 1322, generalised by 2103). +/// +/// Chain first: no MPQ holds an `Interface\AddOns\` path, so the order is unobservable and every +/// client path stays on exactly the code it always ran. +struct FontSource { + chain: Arc>, + /// The ONE AddOns root, from `ui_script::addons::root()` — `None` under `$WOW_CAPTURE`, which + /// is what keeps a capture hermetic for free. + loose_root: Option, +} + /// The font engine: faces, the two caches, and the texture pages. pub(crate) struct TextEngine { font_system: FontSystem, swash: SwashCache, faces: Vec, - /// Blizzard font path (lowercased) → index into [`Self::faces`]. + /// Font path (lowercased) → index into [`Self::faces`]. Seeded with the four client faces and + /// grown on demand by [`Self::face_for`] — an addon's own TTF is a path like any other. path_to_face: HashMap, + /// Where a face the map does not carry is read from: the patch chain, then the one AddOns + /// folder for `Interface\AddOns\…` paths ([`FontSource`], decision 2103). `None` in a VM + /// with no install, which is the only state in which a font path cannot resolve at all. + source: Option, + /// Paths that failed to load, so a miss costs one read and one WARN rather than one per + /// measure. Cleared by nothing: a font file does not appear mid-session. + missing_fonts: HashSet, /// The fallback face (Friz Quadrata) for a FontString with no/unknown font path. default_face: usize, /// The window's `scale_factor` — physical px per logical px. Every raster size derives from it @@ -286,6 +306,11 @@ impl TextEngine { swash: SwashCache::new(), faces, path_to_face, + source: Some(FontSource { + chain: world_assets.chain.clone(), + loose_root: crate::ui_script::addons::root(), + }), + missing_fonts: HashSet::new(), default_face, dpi, chars: HashMap::new(), @@ -299,11 +324,61 @@ impl TextEngine { }) } - /// The face a Blizzard font path resolves to, or the fallback. - pub(super) fn face_for(&self, path: Option<&str>) -> usize { - path.and_then(|p| self.path_to_face.get(&p.to_ascii_lowercase())) - .copied() - .unwrap_or(self.default_face) + /// The face a font path resolves to, loading it on first use, or the fallback. + /// + /// **The four client TTFs are the ones we know the names of, not the ones that exist** + /// (decision 2103). `SetFont`/``/`CreateFont` take an arbitrary path, and an + /// addon that ships its own faces — Mik's Scrolling Battle Text ships thirty-one — names them + /// `Interface\Addons\\Fonts\.ttf`. Before this, every one of those silently + /// resolved to Friz Quadrata: the text drew, in the wrong face, with nothing anywhere saying + /// so. So a path the map does not carry is READ (chain, then the loose AddOns folder — the + /// same two stores in the same order 1322 gave textures) and registered. + /// + /// A path that will not load is remembered in [`Self::missing_fonts`] and warns once, then + /// falls back to Friz exactly as before — the reference degrades the same way (a `CGxFont` + /// that fails to build leaves the string on the font object it inherits). + pub(super) fn face_for(&mut self, path: Option<&str>) -> usize { + let Some(p) = path.filter(|p| !p.is_empty()) else { + return self.default_face; + }; + let key = p.to_ascii_lowercase(); + if let Some(&i) = self.path_to_face.get(&key) { + return i; + } + if self.missing_fonts.contains(&key) { + return self.default_face; + } + match self.load_face(p, &key) { + Some(i) => i, + None => { + warn!( + "ui_text: font miss: '{p}' does not resolve in the patch chain or the AddOns \ + folder — falling back to {}", + CLIENT_FONTS[0] + ); + self.missing_fonts.insert(key); + self.default_face + } + } + } + + /// Read and register one font path. `None` if no store has it or the bytes are not a face. + fn load_face(&mut self, path: &str, key: &str) -> Option { + let source = self.source.as_ref()?; + let bytes = read_font_bytes(source, path)?; + let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794); + let (id, family) = register_font(&mut self.font_system, bytes) + .inspect_err(|e| warn!("ui_text: failed to register {path}: {e:#}")) + .ok()?; + let index = self.faces.len(); + self.faces.push(Face { + id, + family, + ascent_ratio, + }); + self.path_to_face.insert(key.to_string(), index); + info!("ui_text: loaded font face {path}"); + Some(index) } /// **The size law.** A logical height becomes the exact integer device-pixel size it will be @@ -361,8 +436,9 @@ impl TextEngine { /// The baseline-ascender fraction for a font path — the `[CGxFont+0x17c]` load_param, for a /// caller that already holds the lock. - pub(crate) fn ascent_ratio(&self, path: Option<&str>) -> f32 { - self.ascent_ratio_of(self.face_for(path)) + pub(crate) fn ascent_ratio(&mut self, path: Option<&str>) -> f32 { + let face = self.face_for(path); + self.ascent_ratio_of(face) } /// Make sure every character of `text` is in the caches at this `(face, ppem, radius)`, @@ -672,6 +748,22 @@ impl UiFontAtlas { } } +/// Read one font path from the two stores, chain first. +/// +/// The loose leg goes through the same [`benilla_assets::loose_addon_file`] the sprite decoder +/// uses, so it inherits its rules whole: only `Interface\AddOns\` paths reach the folder, the +/// component walk is case-insensitive (MSBT names its own files `Interface\Addons\…` with a +/// lowercase `d`, and ships `mailrays.TTF` while its table says `mailrays.ttf`), and a +/// dot-component is refused before any filesystem call. +fn read_font_bytes(source: &FontSource, path: &str) -> Option> { + if let Ok(bytes) = source.chain.lock_recover().read(path) { + return Some(bytes); + } + let root = source.loose_root.as_deref()?; + let file = benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(path))?; + std::fs::read(file).ok() +} + /// A real-font engine for a test: the client faces, read through the app's own patch chain. `None` /// when there is no install, or the chain or a face will not open — every caller **skips** rather /// than fails (`wow_data_or_skip!`). @@ -701,6 +793,12 @@ pub(super) fn test_engine(dpi: f32) -> Option { swash: SwashCache::new(), faces, path_to_face, + // The same chain the four client faces were just read from, so a test can ask for a fifth. + source: Some(FontSource { + chain: Arc::new(Mutex::new(chain)), + loose_root: None, + }), + missing_fonts: HashSet::new(), default_face, dpi, chars: HashMap::new(), @@ -1032,7 +1130,7 @@ mod ppem_tests { /// Each face resolves to itself, and an unknown path falls back to Friz. #[test] fn a_font_path_resolves_to_its_own_face() { - let Some(e) = engine_or_skip() else { + let Some(mut e) = engine_or_skip() else { return; }; let friz = e.face_for(Some(TEST_FACES[0])); @@ -1041,4 +1139,69 @@ mod ppem_tests { assert_eq!(friz, e.face_for(Some("fonts\\frizqt__.ttf")), "case-folded"); assert_ne!(friz, e.face_for(Some(TEST_FACES[1])), "ARIALN is its own"); } + + /// **An addon-shipped TTF loads out of the ONE AddOns root** (decision 2103) — the leg the bug + /// was: MSBT ships thirty-one faces and names them `Interface\\Addons\\…\\Fonts\\.ttf`, + /// a shape no MPQ carries, and every one of them silently drew as Friz Quadrata. + /// + /// The file is a real TTF (a client face, copied) because the assertion is that it REGISTERED, + /// not merely that a path matched; the reference spelling deliberately mismatches the folder's + /// case on every component, as the ecosystem's do (MSBT writes `Addons`, ships `mailrays.TTF` + /// and asks for `mailrays.ttf`). + #[test] + fn an_addon_shipped_font_loads_out_of_the_addons_root() { + let Some(mut e) = engine_or_skip() else { + return; + }; + let bytes = { + let source = e.source.as_ref().expect("the test engine carries a chain"); + let chain = source.chain.lock_recover(); + chain.read(TEST_FACES[2]).expect("MORPHEUS is in the chain") + }; + let root = std::env::temp_dir().join(format!("benilla-addon-font-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let fonts = root.join("MikScrollingBattleText").join("Fonts"); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("porky.TTF"), &bytes).unwrap(); + e.source.as_mut().unwrap().loose_root = Some(root.clone()); + + let friz = e.face_for(Some(TEST_FACES[0])); + let addon = e.face_for(Some( + "Interface\\Addons\\mikscrollingbattletext\\fonts\\porky.ttf", + )); + assert_ne!( + friz, addon, + "an addon's own face must not fall back to Friz" + ); + assert!( + e.missing_fonts.is_empty(), + "and it must not be recorded as a miss" + ); + // A sibling path under the same root that is not there is still a miss — the folder is a + // store, not a wildcard. + assert_eq!( + friz, + e.face_for(Some( + "Interface\\Addons\\MikScrollingBattleText\\Fonts\\nope.ttf" + )) + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A path that resolves nowhere falls back to Friz and is remembered, so the read and the WARN + /// happen once rather than once per measure. + #[test] + fn an_unresolvable_font_path_is_remembered_as_missing() { + let Some(mut e) = engine_or_skip() else { + return; + }; + let friz = e.face_for(Some(TEST_FACES[0])); + let bogus = "Interface\\Addons\\NoSuchAddon\\Fonts\\nope.ttf"; + assert_eq!(friz, e.face_for(Some(bogus))); + assert!(e.missing_fonts.contains(&bogus.to_ascii_lowercase())); + assert_eq!(friz, e.face_for(Some(bogus)), "and the second ask is free"); + // An empty path is the `SetFont("")` case and is not a miss — it is "no face named". + assert_eq!(friz, e.face_for(Some(""))); + assert!(!e.missing_fonts.contains("")); + } } diff --git a/crates/benilla-app/tests/item_text_frame.rs b/crates/benilla-app/tests/item_text_frame.rs index e0f89e46..7a404703 100644 --- a/crates/benilla-app/tests/item_text_frame.rs +++ b/crates/benilla-app/tests/item_text_frame.rs @@ -331,7 +331,7 @@ fn the_reported_book_crest_draws_at_the_blps_own_size() { let chain = std::sync::Mutex::new(benilla_formats::open_chain(&data).expect("open chain")); let mut s = UiScript::new().unwrap(); - // The host oracle, wired exactly as `ui_script::lifecycle::install_texture_resolvers` wires the + // The host oracle, wired exactly as `ui_script::lifecycle::install_addon_asset_resolvers` wires the // live one: the same decoder, so the size the layout resolves with is the size the screen shows. s.set_texture_size_probe(Box::new(move |path| { benilla_assets::sprite_dimensions(&chain, None, path) diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index 03e6f270..2552406f 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -78,7 +78,7 @@ pub struct WorldAssets { /// here — the caller supplies the RGBA — so this stays a cache, not a second art pipeline. generated: HashMap<&'static str, Handle>, /// The **loose-file root for `Interface\AddOns\` sprite paths** — the AddOns folder, installed - /// by the app ([`Self::set_loose_sprite_root`]), `None` until then and in every capture run + /// by the app ([`Self::set_loose_addon_root`]), `None` until then and in every capture run /// (the app resolves it through `local_state`, which is hermetic under `$WOW_CAPTURE`). /// /// Addon art never lives in an MPQ: the reference's Storm open reads loose files from the game @@ -205,7 +205,7 @@ pub fn sprite_dimensions( /// can't poison the second. /// /// Each candidate is asked of **two stores**: the patch chain, then — for `Interface\AddOns\` -/// paths — the loose addon folder ([`loose_sprite_file`], decision 1322). The chain never holds an +/// paths — the loose addon folder ([`loose_addon_file`], decision 1322). The chain never holds an /// `AddOns\` path (Blizzard's own `Blizzard_*` stubs aside, addon art only exists on disk), so the /// order between the stores is unobservable; chain-first keeps every non-addon path on exactly the /// code it always ran. @@ -222,7 +222,7 @@ fn decode_sprite( return Some(decoded); } } - if let Some(file) = loose_root.and_then(|root| loose_sprite_file(root, candidate)) { + if let Some(file) = loose_root.and_then(|root| loose_addon_file(root, candidate)) { if let Ok(decoded) = std::fs::read(&file) .map_err(anyhow::Error::from) .and_then(|bytes| decode_sprite_bytes(&bytes)) @@ -255,16 +255,21 @@ fn decode_sprite_bytes(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec)> { } } -/// Map a **normalized** sprite candidate (`interface\addons\\<…>.blp`, lowercase, +/// Map a **normalized** addon-relative path (`interface\addons\\<…>`, lowercase, /// backslashed — [`normalize_path`] has run) onto a file under the loose addon root, or `None` if /// it is not an `Interface\AddOns\` path or nothing is there. /// +/// **Not sprite-specific, and named for that** (decision 2103): 1322 built this for addon-shipped +/// BLP/TGA art, and an addon's own TTFs reach the client by exactly the same route — a loose file +/// under the one AddOns root, named by a virtual `Interface\AddOns\…` path no MPQ carries. One +/// resolver, one prefix rule, one sandbox. +/// /// The walk matches each component **case-insensitively** (exact join first — free on the /// case-insensitive filesystems macOS installs default to — then a `read_dir` scan): the reference /// is a Windows client and addons reference their own art in arbitrary case. The candidate cannot /// escape the root — `normalize_path` leaves no `/`, and any dot-component is refused before a /// filesystem call happens (same lexical posture as `ui_script::addons::read_under`). -pub fn loose_sprite_file(root: &Path, candidate: &str) -> Option { +pub fn loose_addon_file(root: &Path, candidate: &str) -> Option { let rel = candidate.strip_prefix("interface\\addons\\")?; let mut at = root.to_path_buf(); for comp in rel.split('\\') { @@ -370,7 +375,7 @@ impl WorldAssets { /// [`Self::loose_root`] field doc. Called by the app once it knows the AddOns folder; evicts /// cached **misses** so a path asked before the root existed gets a second look (a cached hit /// can only have come from the chain and stays right). - pub fn set_loose_sprite_root(&mut self, root: Option) { + pub fn set_loose_addon_root(&mut self, root: Option) { if self.loose_root == root { return; } @@ -726,7 +731,7 @@ mod tests { /// (as [`sprite_candidates`] always hands it over) — the walk must land on the file anyway, /// because the reference is a Windows client and addon authors never matched their own case. #[test] - fn loose_sprite_file_maps_addon_paths_case_insensitively() { + fn loose_addon_file_maps_addon_paths_case_insensitively() { let root = std::env::temp_dir().join(format!("benilla-loose-sprite-test-{}", std::process::id())); let dir = root.join("Atlas").join("Images").join("Maps"); @@ -736,7 +741,7 @@ mod tests { // The returned SPELLING is filesystem-dependent (a case-insensitive filesystem answers // the exact-join fast path with the candidate's own casing), so the claim is that the // path opens the right file, not how it is spelt. - let hit = loose_sprite_file( + let hit = loose_addon_file( &root, "interface\\addons\\atlas\\images\\maps\\blackrockdepths.blp", ) @@ -744,17 +749,17 @@ mod tests { assert_eq!(std::fs::read(&hit).unwrap(), b"x"); // Not an AddOns path → not this store's question. - assert_eq!(loose_sprite_file(&root, "interface\\icons\\foo.blp"), None); + assert_eq!(loose_addon_file(&root, "interface\\icons\\foo.blp"), None); // A directory is not a file, and a missing file is a miss, not an error. - assert_eq!(loose_sprite_file(&root, "interface\\addons\\atlas"), None); + assert_eq!(loose_addon_file(&root, "interface\\addons\\atlas"), None); assert_eq!( - loose_sprite_file(&root, "interface\\addons\\atlas\\images\\maps\\nope.blp"), + loose_addon_file(&root, "interface\\addons\\atlas\\images\\maps\\nope.blp"), None ); // Dot-components never reach the filesystem (the read_under posture; `normalize_path` // already forbids `/`, so this is the one lexical escape left to refuse). assert_eq!( - loose_sprite_file(&root, "interface\\addons\\..\\..\\etc\\passwd"), + loose_addon_file(&root, "interface\\addons\\..\\..\\etc\\passwd"), None ); diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 83c707d8..5be323a5 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -2,6 +2,7 @@ use mlua::{ObjectLike, Table}; use crate::framexml::{self, Element}; +use super::regions::FontAttrs; use super::{abs_dim, children_named, Loader}; impl Loader<'_> { @@ -200,7 +201,7 @@ impl Loader<'_> { return; } }; - self.apply_region_layout(tr, ®ion, self_name, dbg); + self.apply_region_layout(tr, ®ion, self_name, dbg, FontAttrs::Own); } pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) { diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 9162467a..83e54dd7 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -4,6 +4,42 @@ use crate::framexml::{self, Element}; use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader}; +/// Whether a region parses **its own** font attributes — the reference's `FONTSTRING+0x12c`, +/// which wow-re named `ownsFontAttrs` (`scratch/fontstring-loadxml-font-attrs.md` §3 and +/// `scratch/button-label-build-and-anchor-order.md`, both VERIFIED). +/// +/// It exists for exactly one element in the whole schema. The ctor sets it to 1 +/// (`0x770de7 mov byte [esi+0x12c],1`) and **one site image-wide clears it** — `0x778b7b`, inside +/// `CSimpleButton::LoadXML 0x7788c0`'s inline build `[0x778b4c, 0x778bb6)`, which is the +/// **``** leg (tag `0x879978`, compared at `0x778b43`) and nothing else. A +/// `` (tag `0x8799f0`, `0x7789c1`) is built by the ordinary `` region +/// builder `0x6f2780` — the `` walker's own — which never touches the flag, so an ordinary +/// label keeps every attribute it is written with. +/// +/// Cleared, gate B (`0x7710d3` → `je 0x771468`) makes that string's own +/// `CSimpleFontString::LoadXML 0x770f40` skip `0x7710e1`–`0x771467` wholesale: `font=`, +/// ``, `outline=`, `monochrome=`, the file load, `spacing=`, `justifyV=`, `justifyH=`, +/// `` and ``. (Gate A, `0x770f72`, additionally drops the `inherits=` registry-hit +/// live link.) The SAME node is fed instead to the button's persistent Normal-state `CSimpleFont` +/// at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`) — one owner per attribute, no double +/// application. The word still reaches the label's paint and its `GetJustifyH` answer: that +/// loader's tail notify writes the resolved justify into the label's own `+0x120` (`0x784111` → +/// `0x784180` → `0x773530` → `0x770800` at `0x770876`), **after** the anchor is placed. +/// +/// Only `justifyH`/`justifyV` are gated here because they are the only two of that surface this +/// pass ever applied: the rest live in `apply_fontstring_font`/`apply_region_visual`, which the +/// button's label pass does not call. Geometry — ``, ``, `setAllPoints`, `name=` — +/// is `CLayoutFrame::LoadXML`'s and is outside the gate on both legs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FontAttrs { + /// The ctor's default: an ordinary region — a `` ``, an EditBox's special + /// string, a title region, **and a Button's ``** — owns its font attributes. + Own, + /// A Button's ``, the one element the client disowns: its font attributes belong + /// to the button's Normal-state font, never to the label. + Disowned, +} + impl Loader<'_> { /// ``/``/``/`` (rf24 `0x769d70`): create each region /// via CreateTexture/CreateFontString on the draw layer, then apply its file/color/text. @@ -62,7 +98,13 @@ impl Loader<'_> { { self.call_region(®ion_wrapper, "SetAlpha", a, dbg); } - self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg); + self.apply_region_layout( + region, + ®ion_wrapper, + parent_name, + dbg, + FontAttrs::Own, + ); if is_fontstring { // Font object (`inherits=`) first, then the FontString's own `font=`/ // ``/`outline=` overrides — before the generic visual pass applies @@ -154,7 +196,7 @@ impl Loader<'_> { } }, }; - self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg); + self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg, FontAttrs::Own); self.apply_fontstring_font(region, ®ion_wrapper, dbg); self.apply_region_visual(region, ®ion_wrapper, false, dbg); // The creation-path implicit anchor (decision 1310), as in `apply_layers`. For the @@ -184,15 +226,17 @@ impl Loader<'_> { /// A region's own geometry (decision 0068 v1): `` → SetWidth/SetHeight, `` → /// SetPoint (owner-relative; `relativeTo` is `$parent`-substituted like a frame's), the - /// `setAllPoints="true"` shorthand, and the FontString `justifyH` attr → SetJustifyH. This is the - /// join that makes region ``/`` actually place the region (before, both were - /// silently dropped — the smeared-merchant root cause). + /// `setAllPoints="true"` shorthand, and — only for a region that [`FontAttrs::Own`]s them — + /// the FontString `justifyH`/`justifyV` attrs → SetJustifyH/V. This is the join that makes + /// region ``/`` actually place the region (before, both were silently dropped + /// — the smeared-merchant root cause). pub(super) fn apply_region_layout( &mut self, region: &Element, wrapper: &Table, parent_name: &str, dbg: &str, + font_attrs: FontAttrs, ) { // ALL `` children in document order (same last-wins rule as `apply_size`: a templated // region's own `` must overwrite its template's). @@ -205,11 +249,13 @@ impl Loader<'_> { self.call_region(wrapper, "SetHeight", h, dbg); } } - if let Some(j) = region.attr("justifyH") { - self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg); - } - if let Some(j) = region.attr("justifyV") { - self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg); + if font_attrs == FontAttrs::Own { + if let Some(j) = region.attr("justifyH") { + self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg); + } + if let Some(j) = region.attr("justifyV") { + self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg); + } } if region.attr_bool("setAllPoints") { self.call_region(wrapper, "SetAllPoints", (), dbg); diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 2b2fcbc9..03685727 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1557,6 +1557,143 @@ mod loader_tests { ); } + /// **The two spellings of a Button's label are two different legs, and only `` is + /// disowned of its own font attributes — so its `justifyH` never reaches the label and the + /// implicit anchor stays CENTER, while a ``'s does and still seats LEFT.** + /// + /// `CSimpleButton::LoadXML 0x7788c0`'s child chain routes them apart (wow-re + /// `scratch/button-label-build-and-anchor-order.md`, §5 + arbitration, VERIFIED): + /// `` (tag `0x8799f0`, compared `0x7789c1`) goes to `0x7789d0 call 0x6f2780`, the + /// ordinary `` region builder the `` walker uses, which never writes + /// `+0x12c`; `` (tag `0x879978`, `0x778b43`) goes to the inline build + /// `[0x778b4c, 0x778bb6)` carrying `0x778b7b mov byte [edi+0x12c],0` — the one site image-wide + /// that clears the gate. Cleared, gate B (`0x7710d3 → je 0x771468`) makes the label's own + /// `CSimpleFontString::LoadXML 0x770f40` skip `0x7710e1`–`0x771467` wholesale: `font=`, + /// ``, `outline=`, `monochrome=`, the file load, `spacing=`, **`justifyV=`, + /// `justifyH=`**, `` and ``. That node is fed instead to the button's + /// Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`). + /// + /// So the creation post-step `0x771480` reads the `` label's **untouched ctor + /// default** `0x212` = CENTER|MIDDLE (`0x770dd3`) and seats a single CENTER→CENTER anchor + /// (point 4, `0x7714dc`/`0x7714e4`). The authored `justifyH="LEFT"` still reaches the *paint* + /// and `GetJustifyH()` — `0x783c30`'s tail notify writes the resolved word into the label's own + /// `+0x120` (`0x784111` → `0x784180` → `0x773530` → `0x770800` at `0x770876`) — but that runs + /// downstream of the anchor, and a FontString pinned at one point is exactly as wide as its + /// text, so a left justify inside it has nothing to move. + /// + /// The live case is Gatherer 1.0.0's minimap quick-menu, transcribed here: every row is a + /// `GathererUI_PopupButtonTemplate`, all rows are `SetWidth` to one common width and stacked + /// TOP-to-BOTTOM so they share a centreline, and the template's only alignment statement is + /// ``. Applying that word to the label seated all + /// seven rows LEFT→LEFT and the menu read as a left-aligned list against the reference's + /// centred one. + /// + /// Two controls, and the second is why this test carries both spellings: an ordinary + /// `` `` still seats LEFT + /// (`anchorless_fontstring_seats_at_its_justify_point` above), and so does a + /// `` — the gate is the `` leg's alone. + #[test] + fn a_button_label_element_does_not_own_its_justify() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + + + + + + + + + + + + + + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + + // 1 · The anchor — the whole mechanism, read straight off the label. + let (point, rel_point, x, y) = s + .eval::<(String, String, f64, f64)>( + "local p, rel, rp, x, y = GathererUI_PopupButton1:GetFontString():GetPoint(1) \ + return p, rp, x, y", + ) + .unwrap(); + assert_eq!( + (point.as_str(), rel_point.as_str(), x, y), + ("CENTER", "CENTER", 0.0, 0.0), + "'s justifyH is disowned, so 0x771480 seats the ctor's CENTER" + ); + + // 2 · ...and the authored word still reaches the label's PAINT and its getter, through the + // Normal-state font's link. Only the anchor was ever the reference's answer here. + assert_eq!( + s.eval::("return GathererUI_PopupButton1:GetFontString():GetJustifyH()") + .unwrap(), + "LEFT", + "the state font's justify still reaches the label" + ); + + // 3 · The CONTROL, and the half the byte round corrected: `` is built by the + // ordinary FontString builder (`0x6f2780`), which never clears `+0x12c`, so its own + // justifyH lands on the label and the same post-step seats it LEFT. + let (point, rel_point) = s + .eval::<(String, String)>( + "local p, rel, rp = OwnLabelButton:GetFontString():GetPoint(1) return p, rp", + ) + .unwrap(); + assert_eq!( + (point.as_str(), rel_point.as_str()), + ("LEFT", "LEFT"), + " owns its font attributes — the gate is the leg's alone" + ); + + // 4 · The visible consequence, in resolved coordinates. The popup is [0,0]..[120,60] and + // the rows are 64 wide, centred on x=60 → [28,92]. A 40-wide label centred in that is + // [40,80]; seated LEFT it is [28,68] — the left edge every Gatherer row shared before this. + s.run( + "GathererUI_PopupButton1:GetFontString():SetWidth(40) \ + OwnLabelButton:GetFontString():SetWidth(40)", + ) + .unwrap(); + s.resolve(); + let (cl, cr, ll, lr) = s + .eval::<(f64, f64, f64, f64)>( + "local c = GathererUI_PopupButton1:GetFontString() \ + local l = OwnLabelButton:GetFontString() \ + return c:GetLeft(), c:GetRight(), l:GetLeft(), l:GetRight()", + ) + .unwrap(); + assert_eq!( + (cl, cr), + (40.0, 80.0), + "the Gatherer row's label is centred on the button, not hugging its left edge" + ); + assert_eq!( + (ll, lr), + (28.0, 68.0), + "the row's label still hugs the button's left edge" + ); + } + /// **`text=` is a GLOBAL-STRING LOOKUP, not a literal** (wow-re rf28 l.36/l.115 → /// `FrameScript_GetText 0x703bf0`). Every arm of [`Loader::resolve_text`] in one document: /// a `