diff --git a/Cargo.lock b/Cargo.lock index 32aab707..868825d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -594,7 +594,10 @@ dependencies = [ "objc2-foundation 0.2.2", "rand 0.8.6", "raw-window-handle", + "serde", + "serde_json", "smithay-clipboard", + "toml", ] [[package]] @@ -6184,6 +6187,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6749,6 +6761,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -6767,6 +6800,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -6800,6 +6847,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" diff --git a/README.md b/README.md index fa220a7d..ead5c97f 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,77 @@ -# benilla +
+ +

benilla

+

A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy

+

+ Discord + YouTube devlog + License +

+
-[![Discord](https://img.shields.io/badge/chat-Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/wJSJx467G4) -[![YouTube](https://img.shields.io/badge/devlog-YouTube-FF0000?logo=youtube)](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8) -[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) +> [!IMPORTANT] +> **Issues and pull requests are closed here.** benilla is a solo project developed in a private +> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land +> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report +> the bugs you find. Questions and ideas are welcome in the same place. -A from-scratch **World of Warcraft 1.12.1 client** in **Rust + [Bevy](https://bevy.org)**, speaking -the vanilla protocol to a [vmangos](https://github.com/vmangos/core) server. Every file format and -the network protocol are implemented from scratch — no original client code, no third-party WoW -crates, and no bundled game assets: benilla reads its data at runtime from your own 1.12.1 install. +benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could, +and reads its game data at runtime from your own 1.12.1 install. Every file format and the network +protocol are implemented from scratch, with no original client code, no third-party WoW crates, +and no bundled game assets. ## What works -- **Formats & assets** — in-repo readers for the full asset stack (MPQ patch chain, BLP, DBC, - ADT/WDT/WDL, M2, WMO), wired into Bevy as an asset source, plus extraction CLI tools. -- **World** — streamed terrain with the distant horizon, portal-culled WMOs with interior lighting, +- **Formats:** readers for the full asset stack (MPQ patch chain, BLP, DBC, ADT/WDT/WDL, M2, WMO), + wired into Bevy as an asset source. +- **World:** streamed terrain out to the horizon, portal-culled WMOs with interior lighting, doodads and ground clutter, swimmable liquids, sky and weather, and the client's own day/night - lighting, fog, glow, and gamma passes. -- **Models & animation** — GPU-skinned M2s with the full animation controller (cross-fades, - one-shots, fidgets), a near feature-complete particle system, ribbons, and animated gameobjects - (doors, chests, mailboxes, lifts). -- **Characters** — customization end to end, the armor-region texture composite, held weapons with - sheathing and enchant glows, shapeshift forms, stealth's fade, and mounts. -- **Movement & travel** — a WoW-feel controller (run/strafe/jump/swim/step-up), networked movement - in both directions, the server-granted modes (slow fall, levitate, water walking, roots and - stuns), a follow-camera with collision, ridden boats and zeppelins, and taxi flights. -- **Networking** — SRP6 auth through world-session crypto, the object-descriptor mirror into the - ECS, and live wire coverage for movement, chat, spells and auras, party, duels, inspect, the - social lists, mail, trade, vendors, bank, trainers, professions, quests, loot with group rolls, - item use and locks, and death. -- **UI** — a from-scratch FrameXML + Lua UI engine driving the built-in interface: login and - character screens, the full HUD (cast bars, breath and fatigue timers, combo points), the classic - windows (bags and keyring, spellbook, talents, quests, professions, social, inspect, mail, - merchant, trade, bank, world map, …), chat with its slash commands, nameplates, floating combat - text, chat bubbles, and tooltips. -- **Combat** — melee with the faithful swing law, ranged and Auto Shot, casting with GCD, cooldowns - and the cancel ladder, combo points, ground-targeted casting, crowd control that really holds you - (roots, stuns, the Ice Block freeze), and the spell-visual pipeline (kits, missiles, impacts, - ground rings). -- **Audio** — music, ambience, and SFX under the client's own selection and crossfade rules, with - interior/underwater transitions and zone reverb. + lighting, fog and gamma passes. +- **Models:** GPU-skinned M2s with the full animation controller, a near feature-complete particle + system, ribbons, and animated gameobjects from doors to lifts. +- **Characters:** customization end to end, the armor texture composite, weapons with sheathing and + enchant glows, shapeshift forms, stealth and mounts. +- **Movement:** a WoW-feel controller, networked movement in both directions, the server-granted + modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights. +- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live + wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank + and loot. +- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and + character screens through the full HUD, the classic windows, chat, nameplates, floating combat + text and tooltips. +- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and + cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline. +- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with + interior and underwater transitions and zone reverb. ## Not built yet -Guilds (past the name over a head), the auction house, hunter/warlock pet control (the pet bar), -battlegrounds, honor and the zone-control PvP overlays (the PvP flag itself shows), the fishing -minigame, camera-possession spells (Mind Vision, Sentry Totem), the try-on preview (dressing -room), the macros/options/key-binding screens, and third-party addon loading — the Lua/FrameXML -engine currently runs only the built-in UI. +Guilds past the name over a head, the auction house, the hunter and warlock pet bar, battlegrounds +and honor, the fishing minigame, the dressing room, macros and key bindings, and third-party +addons; the Lua engine runs only the built-in UI so far. ## Running it -You need a **1.12.1 (build 5875) client install** for game data, a -[vmangos](https://github.com/vmangos/core) server to connect to, and stable Rust. +You need a **1.12.1 (build 5875) client install** for game data, a vanilla server to connect to, +and stable Rust. Any 1.12.1 core works; [vmangos](https://github.com/vmangos/core) is what +development runs against, and cMaNGOS and the rest speak the same protocol. ```sh WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla ``` -The server defaults to `localhost:3724`, where a stock vmangos `realmd` listens — point `WOW_HOST` -at any IP or hostname, and append an auth port if yours is remapped: `WOW_HOST=play.example.com:5000`. -Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it. +The server defaults to `localhost:3724`, the stock `realmd` auth port. Point `WOW_HOST` +at any IP or hostname, appending the auth port if yours is remapped +(`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` / +`WOW_PASS` to skip it. -## Contributing +--- -benilla is a solo project and isn't taking pull requests for now. Bug reports and questions are -welcome — bring them to the [Discord](https://discord.gg/wJSJx467G4) (issues are disabled here on -purpose). Comments throughout the code cite numbered -decision records and method notes from a private development log; the citations won't resolve -here, but the reasoning they carry is kept in the comments themselves. +Early inspiration and file format guidance came from the +[wowemulation-dev](https://github.com/wowemulation-dev) community, and +[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular. -Development happens in that private tree, and this repo is its published export: **each commit here -is one squashed snapshot** of everything that landed since the last one, titled with what changed — -not an atomic change, which is why they're large and why there are few of them. - -## Acknowledgements - -The [wowemulation-dev](https://github.com/wowemulation-dev) community — and -[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular — was an early -inspiration and a source of guidance on the client's file formats. benilla's readers ended up -written from scratch, but their preservation work made starting far less daunting. And none of -this runs without [vmangos](https://github.com/vmangos/core) on the other end of the socket. - -## Legal - -benilla is an independent, fan-made project, not affiliated with or endorsed by Blizzard -Entertainment. It contains no Blizzard code or assets and never will; you must provide your own -legally-obtained 1.12.1 client for game data. World of Warcraft is a trademark of Blizzard -Entertainment, Inc. - -## License - -Licensed under either of the [Apache License 2.0](LICENSE-APACHE) or the [MIT -license](LICENSE-MIT), at your option. +benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment. +It contains no Blizzard code or assets, and you provide your own legally obtained 1.12.1 client +for game data. World of Warcraft is a trademark of Blizzard Entertainment, Inc. Licensed under +[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. diff --git a/banner.jpg b/banner.jpg new file mode 100644 index 00000000..36b2def4 Binary files /dev/null and b/banner.jpg differ diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index f1f3c8b2..16929fa5 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -198,7 +198,25 @@ impl Loader<'_> { ) { let color = children_named(region, "Color").next().map(color_of); if is_texture { - if let Some(file) = region.attr("file") { + if let Some(atlas) = region.attr("atlas") { + // `atlas="member"` (decision 0950): the Era dialect's texture reference — one + // SetAtlas resolves sheet + UV sub-rect (+ nominal size with `useAtlasSize`) + // through the app-pushed manifest table; `file=` remains the 1.12 MPQ path. + self.call_region( + region_wrapper, + "SetAtlas", + (atlas.to_string(), region.attr_bool("useAtlasSize")), + dbg, + ); + if let Some(c) = color { + self.call_region( + region_wrapper, + "SetVertexColor", + (c[0], c[1], c[2], c[3]), + dbg, + ); + } + } else if let Some(file) = region.attr("file") { self.call_region(region_wrapper, "SetTexture", file.to_string(), dbg); if let Some(c) = color { // A texture with both a file and a is a tint (vertex color). diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs new file mode 100644 index 00000000..9c5e4462 --- /dev/null +++ b/crates/benilla-ui/src/script/cvars.rs @@ -0,0 +1,247 @@ +//! The **CVar table** — the client's named-console-variable store, engine side (decision 0954). +//! +//! In the reference every player setting is a CVar: a case-insensitively named string value with +//! a registered default, read and written from Lua through `GetCVar`/`SetCVar`/`GetCVarDefault` +//! (the 1.12 options panels are thin UI over this table). benilla keeps the same shape at the +//! same seam: the **host registers** the vars it actually backs with engine knobs +//! ([`super::UiScript::register_cvars`] — the honest-tree rule: no key without a knob), Lua +//! reads/writes them synchronously, and each Lua write lands on the **change queue** the host +//! drains per frame ([`super::UiScript::take_cvar_changes`]) to sync its resources and mark the +//! config file dirty. Host-side writes ([`super::UiScript::set_cvar_host`] — the loaded config, +//! an env override) do NOT ride the queue: the host already knows, and an echo would re-dirty +//! the file it just loaded. +//! +//! Values are **strings**, like the client's (`GetCVar` returns a string; consumers parse and +//! clamp at their own edge). An unknown name warns once and no-ops — every benilla CVar is +//! host-registered, so an unknown key is a typo or an unshipped feature, never a storage slot. +//! Divergence, disclosed: the client also surfaces `RegisterCVar` to Lua; nothing in our shipped +//! XML calls it, so it is not modeled until something does. + +use mlua::{Lua, Value}; + +use super::Model; + +/// One registered CVar: the host's spelling (for change events and the config file), the live +/// value, and the registered default (`GetCVarDefault`, and the saver's "only write what moved"). +#[derive(Clone, Debug)] +pub(crate) struct CvarSlot { + /// The registered spelling (`MasterVolume`), reported on change events and in snapshots; + /// the table key is the lowercase form (the client's lookups are case-insensitive). + pub name: String, + pub value: String, + pub default: String, +} + +impl super::UiScript { + /// Register the host-backed CVars (name, default) — boot-time, idempotent. A re-register of + /// a live table refreshes defaults but never clobbers a value someone already set. + pub fn register_cvars<'a>(&mut self, vars: impl IntoIterator) { + let mut model = self.model_mut(); + for (name, default) in vars { + let key = name.to_ascii_lowercase(); + match model.cvars.get_mut(&key) { + Some(slot) => slot.default = default.to_string(), + None => { + model.cvars.insert( + key, + CvarSlot { + name: name.to_string(), + value: default.to_string(), + default: default.to_string(), + }, + ); + } + } + } + } + + /// Host-side write (loaded config, env override): sets the value WITHOUT queueing a change + /// event — the host is the caller, an echo would re-dirty the file it just read. Unknown + /// names warn once, same posture as the Lua side. + pub fn set_cvar_host(&mut self, name: &str, value: &str) { + let mut model = self.model_mut(); + let key = name.to_ascii_lowercase(); + match model.cvars.get_mut(&key) { + Some(slot) => slot.value = value.to_string(), + None => warn_unknown(&mut model, name), + } + } + + /// Read one CVar's live value (host side). + pub fn cvar(&self, name: &str) -> Option { + self.model_mut() + .cvars + .get(&name.to_ascii_lowercase()) + .map(|s| s.value.clone()) + } + + /// Snapshot the whole table as `(name, value, default)` — the saver writes the entries whose + /// value moved off the default, and nothing else (the config file stays a diff, not a dump). + pub fn cvars_snapshot(&self) -> Vec<(String, String, String)> { + self.model_mut() + .cvars + .values() + .map(|s| (s.name.clone(), s.value.clone(), s.default.clone())) + .collect() + } + + /// Drain the `(name, new_value)` changes Lua `SetCVar` queued since the last call — the + /// host's cue to sync its resources and mark the config dirty. Names are the registered + /// spelling regardless of the caller's casing. + pub fn take_cvar_changes(&mut self) -> Vec<(String, String)> { + std::mem::take(&mut self.model_mut().cvar_changes) + } +} + +/// Push the warn-once for an unknown CVar name into the model's warning stream. +fn warn_unknown(model: &mut Model, name: &str) { + let key = name.to_ascii_lowercase(); + if model.cvars_warned.insert(key) { + model.warnings.push(format!( + "unknown CVar '{name}' (not host-registered) — ignored" + )); + } +} + +/// Coerce the Lua argument to the string the table stores: the client stringifies numbers and +/// booleans the same way (`SetCVar("MusicVolume", 0.4)` is the common call shape). +fn value_to_string(v: &Value) -> Option { + match v { + Value::String(s) => s.to_str().ok().map(|s| s.to_owned()), + Value::Integer(i) => Some(i.to_string()), + Value::Number(n) => Some(n.to_string()), + Value::Boolean(b) => Some(if *b { "1".into() } else { "0".into() }), + _ => None, + } +} + +/// Register the `GetCVar`/`SetCVar`/`GetCVarDefault` globals. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + lua.globals().set( + "GetCVar", + lua.create_function(|lua, name: String| { + let mut model = lua.app_data_mut::().expect("model app_data"); + match model.cvars.get(&name.to_ascii_lowercase()) { + Some(slot) => Ok(Value::String(lua.create_string(&slot.value)?)), + None => { + warn_unknown(&mut model, &name); + Ok(Value::Nil) + } + } + })?, + )?; + lua.globals().set( + "GetCVarDefault", + lua.create_function(|lua, name: String| { + let mut model = lua.app_data_mut::().expect("model app_data"); + match model.cvars.get(&name.to_ascii_lowercase()) { + Some(slot) => Ok(Value::String(lua.create_string(&slot.default)?)), + None => { + warn_unknown(&mut model, &name); + Ok(Value::Nil) + } + } + })?, + )?; + lua.globals().set( + "SetCVar", + lua.create_function(|lua, args: mlua::MultiValue| { + let mut it = args.iter(); + let (Some(Value::String(name)), Some(v)) = (it.next(), it.next()) else { + return Err(mlua::Error::runtime("Usage: SetCVar(\"name\", value)")); + }; + let name = name.to_str()?.to_owned(); + let Some(value) = value_to_string(v) else { + return Err(mlua::Error::runtime("Usage: SetCVar(\"name\", value)")); + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let key = name.to_ascii_lowercase(); + match model.cvars.get_mut(&key) { + Some(slot) => { + if slot.value != value { + slot.value = value.clone(); + let reg_name = slot.name.clone(); + model.cvar_changes.push((reg_name, value)); + } + } + None => warn_unknown(&mut model, &name), + } + Ok(()) + })?, + ) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + fn script_with_volume() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.register_cvars([("MusicVolume", "0.4"), ("MasterVolume", "1.0")]); + s + } + + #[test] + fn the_table_round_trips_and_queues_changes_case_insensitively() { + let mut s = script_with_volume(); + // Registration seeds value = default; reads are case-insensitive. + assert_eq!( + s.eval::(r#"return GetCVar("musicvolume")"#) + .unwrap(), + "0.4" + ); + // A Lua write queues ONE change under the REGISTERED spelling; the number stringifies. + s.run(r#"SetCVar("MUSICVOLUME", 0.7)"#).unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("MusicVolume".to_string(), "0.7".to_string())] + ); + assert!(s.take_cvar_changes().is_empty(), "drained"); + // The default is unmoved and separately readable. + assert_eq!( + s.eval::(r#"return GetCVarDefault("MusicVolume")"#) + .unwrap(), + "0.4" + ); + assert_eq!(s.cvar("MusicVolume").as_deref(), Some("0.7")); + // A write to the same value queues nothing (quiet frames stay quiet). + s.run(r#"SetCVar("MusicVolume", "0.7")"#).unwrap(); + assert!(s.take_cvar_changes().is_empty()); + } + + #[test] + fn host_writes_do_not_echo_and_snapshots_carry_defaults() { + let mut s = script_with_volume(); + s.set_cvar_host("MasterVolume", "0.25"); + assert!( + s.take_cvar_changes().is_empty(), + "a host write must not re-dirty the config it just loaded" + ); + assert_eq!( + s.eval::(r#"return GetCVar("MasterVolume")"#) + .unwrap(), + "0.25" + ); + let snap = s.cvars_snapshot(); + let master = snap.iter().find(|(n, _, _)| n == "MasterVolume").unwrap(); + assert_eq!((master.1.as_str(), master.2.as_str()), ("0.25", "1.0")); + } + + #[test] + fn unknown_names_warn_once_and_no_op() { + let mut s = script_with_volume(); + assert!(s + .eval::(r#"return GetCVar("bogusKnob") == nil"#) + .unwrap()); + s.run(r#"SetCVar("bogusKnob", 1)"#).unwrap(); + s.run(r#"SetCVar("bogusKnob", 2)"#).unwrap(); + assert!(s.take_cvar_changes().is_empty()); + let warns: Vec = s.take_warnings(); + assert_eq!(warns.len(), 1, "warn-once: {warns:?}"); + assert!(warns[0].contains("bogusKnob")); + // Registration is idempotent and never clobbers a live value. + s.set_cvar_host("MusicVolume", "0.9"); + s.register_cvars([("MusicVolume", "0.4")]); + assert_eq!(s.cvar("MusicVolume").as_deref(), Some("0.9")); + } +} diff --git a/crates/benilla-ui/src/script/editbox/seam.rs b/crates/benilla-ui/src/script/editbox/seam.rs index 8321a5a3..8f6560d1 100644 --- a/crates/benilla-ui/src/script/editbox/seam.rs +++ b/crates/benilla-ui/src/script/editbox/seam.rs @@ -26,6 +26,13 @@ impl UiScript { let font = d.and_then(|d| d.font_path.clone()); let height = d.and_then(|d| d.font_height); let outline = d.map(|d| d.outline).unwrap_or_default(); + // The box's effective_scale rides the request ([`types::EditBoxAdvanceRequest::scale`]): + // the host measures at the drawn raster size so the advances land on the drawn glyphs. + let scale = model + .arena + .frame(h) + .map(|f| f.effective_scale) + .unwrap_or(1.0); // A multiline box also needs its wrap rows — at the text region's resolved width, the // same width the draw wraps at. Folded into the cache key: a resize re-wraps. let wrap_width = multi_line @@ -44,6 +51,7 @@ impl UiScript { height.map(f32::to_bits).hash(&mut hasher); (outline as u8).hash(&mut hasher); wrap_width.map(f32::to_bits).hash(&mut hasher); + scale.to_bits().hash(&mut hasher); let key = hasher.finish(); let id = model.frame_id(h); let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) @@ -64,6 +72,7 @@ impl UiScript { font, height, outline, + scale, text: display, wrap_width, key, diff --git a/crates/benilla-ui/src/script/era_atlas_tests.rs b/crates/benilla-ui/src/script/era_atlas_tests.rs new file mode 100644 index 00000000..a279165e --- /dev/null +++ b/crates/benilla-ui/src/script/era_atlas_tests.rs @@ -0,0 +1,127 @@ +//! The Era atlas seam (decision 0950): `atlas=` in XML, `SetAtlas`/`GetAtlas` at runtime, and the +//! warn-once miss drain — against a hand-pushed table. Disk and manifest.json belong to the app; +//! these tests prove the script/loader side alone. + +use crate::framexml; +use crate::loader; +use crate::script::{EraAtlasEntry, UiScript}; + +/// The real `options_innerframe` numbers (fdid 1318750, rect [1,150,887,768] in 1024×1024) so a +/// regression here reads like the manifest it mirrors. +fn harness() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.set_era_atlases([ + ( + "options_innerframe".to_string(), + EraAtlasEntry { + file: "era:textures/1318750.blp".to_string(), + uv: [1.0 / 1024.0, 887.0 / 1024.0, 150.0 / 1024.0, 768.0 / 1024.0], + size: [886.0, 618.0], + }, + ), + ( + "checkbox-minimal".to_string(), + EraAtlasEntry { + file: "era:textures/4614134.blp".to_string(), + uv: [1.0 / 64.0, 31.0 / 64.0, 1.0 / 64.0, 30.0 / 64.0], + size: [30.0, 29.0], + }, + ), + ]); + s +} + +#[test] +fn xml_atlas_attribute_resolves_texture_uv_and_size() { + let mut s = harness(); + let doc = framexml::parse( + r#" + + + + + + + + "#, + ) + .unwrap(); + let report = loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + + // Case-insensitive resolve; the canonical (lowercase) name is what GetAtlas reports. + assert_eq!( + s.eval::("return TInner:GetAtlas()").unwrap(), + "options_innerframe" + ); + let (l, r, t, b): (f32, f32, f32, f32) = s.eval("return TInner:GetTexCoord()").unwrap(); + assert!((l - 1.0 / 1024.0).abs() < 1e-6 && (r - 887.0 / 1024.0).abs() < 1e-6); + assert!((t - 150.0 / 1024.0).abs() < 1e-6 && (b - 768.0 / 1024.0).abs() < 1e-6); + // useAtlasSize applied the member's nominal size. + assert_eq!(s.eval::("return TInner:GetWidth()").unwrap(), 886.0); + assert_eq!(s.eval::("return TInner:GetHeight()").unwrap(), 618.0); + // Nothing missed. + assert!(s.take_era_atlas_misses().is_empty()); +} + +#[test] +fn runtime_setatlas_swaps_and_settexture_clears() { + let s = harness(); + let doc = framexml::parse( + r#" + + + + + + + + "#, + ) + .unwrap(); + let report = loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + + // The category-row idiom: swap atlas at runtime (hover/selected), size riding along. + s.run("TTex:SetAtlas(\"checkbox-minimal\", true)").unwrap(); + assert_eq!( + s.eval::("return TTex:GetAtlas()").unwrap(), + "checkbox-minimal" + ); + assert_eq!(s.eval::("return TTex:GetWidth()").unwrap(), 30.0); + + // A plain SetTexture makes the region ordinary again — GetAtlas goes nil. + s.run("TTex:SetTexture(\"Interface\\\\Buttons\\\\UI-Panel-Button-Up\")") + .unwrap(); + assert!(s.eval::("return TTex:GetAtlas() == nil").unwrap()); +} + +#[test] +fn unknown_atlas_is_a_warn_once_miss_not_an_error() { + let mut s = harness(); + let doc = framexml::parse( + r#" + + + + + + + + "#, + ) + .unwrap(); + // The load survives (a stale extraction must not kill the whole XML pass) … + let report = loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + // … the region draws nothing … + assert!(s.eval::("return TTex:GetAtlas() == nil").unwrap()); + // … and the miss names itself exactly once, then the drain clears. + s.run("TTex:SetAtlas(\"not-extracted-yet\")").unwrap(); + assert_eq!( + s.take_era_atlas_misses(), + vec!["not-extracted-yet".to_string()] + ); + assert!(s.take_era_atlas_misses().is_empty()); +} diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index c4ab81bf..e979c42a 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -24,10 +24,11 @@ impl UiScript { // it is clipped by (nested ScrollFrames intersect). let scroll_sources = scroll_clip_sources(&model); for (target, zkey) in list { - let (rect, alpha, content, clip) = match target { + let (rect, alpha, content, clip, scale) = match target { ZTarget::Frame(fh) => { let frame = model.arena.frame(fh); let alpha = frame.map(|f| f.effective_alpha).unwrap_or(1.0); + let scale = frame.map(|f| f.effective_scale).unwrap_or(1.0); let clip = effective_clip(&model, &scroll_sources, fh); // A Minimap widget's own slot carries its zoom out to the app renderer (the // tile/mask/arrow draw — decision 0203); every other frame slot is bare. @@ -50,7 +51,13 @@ impl UiScript { } _ => QuadContent::Frame, }; - (model.resolved.get(&fh).copied(), alpha, content, clip) + ( + model.resolved.get(&fh).copied(), + alpha, + content, + clip, + scale, + ) } ZTarget::Region(rh) => { let region = model.arena.region(rh); @@ -210,7 +217,10 @@ impl UiScript { rotation: data.rotation, } }; - (rect, alpha, content, clip) + // A region draws at its owner's scale — same single hop as alpha (a region + // has no scale of its own; `propagation.md`'s product lives on frames). + let scale = owner_frame.map(|f| f.effective_scale).unwrap_or(1.0); + (rect, alpha, content, clip, scale) } }; out.push(ExtractedQuad { @@ -220,6 +230,7 @@ impl UiScript { alpha, content, clip, + scale, }); // A ScrollingMessageFrame emits, on top of its own (empty) frame slot, one Text quad per // visible ring line — stacked bottom-up (newest at the frame's bottom edge), each carrying @@ -233,13 +244,14 @@ impl UiScript { // stay behind them. At the bare slot the chat window's hover box painted over its // own messages. Both clip like the frame's own slot (the frame IS the clipped owner // when it's a scroll child). - Self::emit_backdrop(&model, fh, fr, zkey.raw(), alpha, clip, &mut out); + let paint = super::layout::FramePaint { alpha, scale }; + Self::emit_backdrop(&model, fh, fr, zkey.raw(), paint, clip, &mut out); Self::emit_message_lines( &model, fh, fr, zkey.content(order::DrawLayer::Artwork).raw(), - alpha, + paint, clip, &mut out, ); diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 59662ed5..06502a86 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -10,6 +10,16 @@ use super::{ Outline, QuadContent, UiScript, SCREEN, }; +/// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale` +/// travelling together (backdrop pieces and message-frame ring lines are emitted BY the frame, +/// so they wear the frame's own factors exactly like [`ExtractedQuad::alpha`]/`scale` on the +/// slot itself). +#[derive(Clone, Copy)] +pub(super) struct FramePaint { + pub(super) alpha: f32, + pub(super) scale: f32, +} + /// A 128-bit rolling fingerprint of everything [`UiScript::resolve_layout`] *reads*, so a resolve /// whose inputs are byte-identical to the last one can be skipped outright. /// @@ -598,6 +608,14 @@ impl UiScript { if !is_fs { continue; } + // The owner's effective_scale: the host measures at the drawn raster size + // ([`MeasureRequest::scale`]), and the key carries it so a SetScale re-measures. + let scale = model + .arena + .region(rh) + .and_then(|r| model.arena.frame(r.owner)) + .map(|f| f.effective_scale) + .unwrap_or(1.0); let d = model.region_data.get(&rh).expect("live region data"); let text = d.text.clone().unwrap_or_default(); let wrap_width = d.size.map(|s| s.0).filter(|w| *w > 0.0); @@ -607,7 +625,7 @@ impl UiScript { let outline = d.outline; // The shared key recipe ([`RegionData::measure_key`]) — the metric reads // (region.rs) check the stored measure against the same hash. - let key = d.measure_key(); + let key = d.measure_key(scale); if d.measured.map(|m| m.key) == Some(key) { continue; } @@ -619,6 +637,7 @@ impl UiScript { text_height, wrap_width, outline, + scale, text, key, }); @@ -650,6 +669,11 @@ impl UiScript { if wrap_width <= 1.0 { continue; // unresolved/degenerate width — nothing meaningful to wrap against } + let scale = model + .arena + .frame(fh) + .map(|f| f.effective_scale) + .unwrap_or(1.0); let (font, height, _, outline) = Self::message_frame_font(&model, fh); let frame_id = model.frame_id(fh); let Some(frame) = model.arena.frame(fh) else { @@ -665,6 +689,7 @@ impl UiScript { height.map(f32::to_bits).hash(&mut hasher); wrap_width.to_bits().hash(&mut hasher); (outline as u8).hash(&mut hasher); + scale.to_bits().hash(&mut hasher); let key = hasher.finish(); if line.rows_key == key { continue; @@ -676,6 +701,7 @@ impl UiScript { height, wrap_width, outline, + scale, text: line.text.clone(), key, }); @@ -710,13 +736,13 @@ impl UiScript { /// frame has none). Pieces carry the frame slot's `z` — behind the frame's regions (which sort /// after it) and, among themselves, bg-then-border in paint order (the stable z-sort keeps the /// emission order). Each piece's rect is its screen bounding box; the app resolves the texture - /// from the piece path and multiplies the tint by `frame_alpha`. + /// from the piece path and multiplies the tint by the [`FramePaint`]'s alpha. pub(super) fn emit_backdrop( model: &Model, fh: FrameHandle, fr: Rect, z: u64, - frame_alpha: f32, + paint: FramePaint, clip: Option, out: &mut Vec, ) { @@ -751,7 +777,7 @@ impl UiScript { target: ZTarget::Frame(fh), z, rect: Some(Rect::new(bottom, left, top, right)), - alpha: frame_alpha, + alpha: paint.alpha, content: QuadContent::Backdrop { path, color, @@ -759,6 +785,7 @@ impl UiScript { tile: piece.tile, }, clip, + scale: paint.scale, }); } } @@ -782,7 +809,7 @@ impl UiScript { fh: FrameHandle, fr: Rect, z: u64, - frame_alpha: f32, + paint: FramePaint, clip: Option, out: &mut Vec, ) { @@ -796,7 +823,10 @@ impl UiScript { return; } let (font, font_height, font_shadow, outline) = Self::message_frame_font(model, fh); - let pitch = font_height.unwrap_or(14.0); + // The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch + // rides the frame scale exactly like the glyphs it must coincide with (the font height + // itself is frame-local). + let pitch = font_height.unwrap_or(14.0) * paint.scale; if pitch <= 0.0 || fr.top <= fr.bottom { return; } @@ -825,7 +855,7 @@ impl UiScript { target: ZTarget::Frame(fh), z, rect: Some(band), - alpha: frame_alpha, + alpha: paint.alpha, clip: line_clip, content: QuadContent::Text { text: Some(line.text.clone()), @@ -850,6 +880,7 @@ impl UiScript { outline, alpha_gradient: None, }, + scale: paint.scale, }); } } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index d19caa49..6ec823bd 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -50,7 +50,10 @@ mod cursor; mod death; mod editbox; pub(crate) use editbox::adopt_text_region; +mod cvars; mod duel; +#[cfg(test)] +mod era_atlas_tests; mod event; mod extract; mod follow; @@ -143,9 +146,9 @@ pub use trainer::{ TrainerState, }; pub use types::{ - EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow, - JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue, - TexCoords, + EditAction, EditBoxTextUi, EditOutcome, EditUnit, EraAtlasEntry, ExtractedQuad, FontObject, + FontShadow, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, + ScriptValue, TexCoords, }; pub(crate) use types::{MeasuredText, RegionData}; pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState}; @@ -266,6 +269,7 @@ impl UiScript { pvp::install(&lua)?; death::install(&lua)?; aura::install(&lua)?; + cvars::install(&lua)?; sound::install(&lua)?; pointer::install(&lua)?; action::install(&lua)?; @@ -328,6 +332,27 @@ impl UiScript { } } + /// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads. + /// Keys are stored lowercased (the DB2 dump's own convention); `SetAtlas`/`atlas=` match + /// case-insensitively. The app builds the entries from `WoW-era/_extracted_ui/manifest.json`; + /// the script side only stores resolved paint, never touching disk. + pub fn set_era_atlases(&mut self, entries: impl IntoIterator) { + let mut model = self.model_mut(); + model.era_atlas = entries + .into_iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v)) + .collect(); + } + + /// Drain the atlas names `SetAtlas` failed to resolve (each reported once, sorted) — the app + /// logs them as WARNs pointing at `scripts/era-extract.py`: a stale extraction must name + /// itself, never silently draw blank. + pub fn take_era_atlas_misses(&mut self) -> Vec { + let mut misses: Vec = self.model_mut().era_atlas_missing.drain().collect(); + misses.sort(); + misses + } + /// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/ /// `IsAltKeyDown`. The app's input pass calls this BEFORE feeding the frame's mouse events, so /// a click handler's modifier fork (the reference's shift-split / ctrl-dressup / @@ -497,6 +522,43 @@ impl UiScript { } } + /// Drop every cached host text metric — FontString measures, message-line row counts, editbox + /// advance tables. The host calls this when its **raster environment** changes (a window + /// resize / fullscreen toggle / uiScale move — anything that shifts the px-per-unit seam its + /// answers were taken under): glyph advances step to whole pixels at the drawn raster size, + /// so a metric measured under one environment does not rescale to another — the font-size + /// snap alone moves a string's unit width by several percent, which is exactly enough for a + /// boot-size measure to fail the post-resize ellipsis fit test and truncate text that fits + /// (the director's fullscreen "Contr..." rows). The environment is the host's to watch — the + /// engine deliberately never sees the seam scale — so staleness is the host's to declare; the + /// round-trips re-answer on the frames that follow (the same one-frame convergence every + /// measure already has). + pub fn invalidate_text_measures(&mut self) { + let mut model = self.model_mut(); + for d in model.region_data.values_mut() { + d.measured = None; + } + // The frame-side caches key on content hashes; a bumped stored key can never equal the + // recomputed one, so the next needing-measure sweep re-requests without this method + // having to know what the keys hash. + for (_, frame) in model.arena.iter_frames_mut() { + match &mut frame.kind_state { + KindState::ScrollingMessage(smf) => { + for line in &mut smf.lines { + line.rows_key = line.rows_key.wrapping_add(1); + } + } + KindState::EditBox(eb) => { + eb.advances_key = eb.advances_key.wrapping_add(1); + } + _ => {} + } + } + // Measured extents are auto-size inputs — the layout gate's read set, same as + // [`Self::set_measured_text`]'s. + model.touch_layout(); + } + /// Feed the number font's per-digit advance widths (logical px, `'0'..='9'` in order) as the /// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`). The /// synchronous half of the money layout's metrics (the real `SmallMoneyFrame` calls diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 4dd3a0c4..6bc5e0c7 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -6,8 +6,8 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena}; use super::{ backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect, item_text, loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider, - social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject, - ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, + social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, EraAtlasEntry, + FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, }; // ───────────────────────────────────────────────────────────────────────────────────────────── @@ -71,6 +71,13 @@ pub(crate) struct Model { pub(crate) chat_tab: bool, /// Region visuals (texture path/color/text) + region layout (anchors/size/justify). pub(crate) region_data: HashMap, + /// The Era atlas table (decision 0950): lowercased member name → resolved paint. Pushed once + /// by [`super::UiScript::set_era_atlases`] before the XML loads; read by `SetAtlas` and the + /// loader's `atlas=` attribute. + pub(crate) era_atlas: HashMap, + /// Atlas names `SetAtlas` could not resolve — warn-once, drained by + /// [`super::UiScript::take_era_atlas_misses`] for app-side WARNs. + pub(crate) era_atlas_missing: HashSet, /// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `` or /// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data` /// (the arena models structure, not paint). The client's `frame+0x1ac` pointer. @@ -199,6 +206,15 @@ pub(crate) struct Model { /// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]). pub(crate) sound_queue: Vec, + /// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered + /// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`. + pub(crate) cvars: HashMap, + /// `(registered name, new value)` per Lua `SetCVar` since the app's last + /// [`super::UiScript::take_cvar_changes`] drain — the knob-sync + config-dirty cue. + pub(crate) cvar_changes: Vec<(String, String)>, + /// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture). + pub(crate) cvars_warned: HashSet, + /// The action-slot snapshot (keyed by Lua action id 1..120) + the stance page offset the app /// pushes, and the `UseAction` intents it drains — the action seam ([`action`]). pub(crate) actions: HashMap, @@ -686,6 +702,8 @@ impl Model { link_spans: HashMap::new(), chat_tab: false, region_data: HashMap::new(), + era_atlas: HashMap::new(), + era_atlas_missing: HashSet::new(), backdrops: HashMap::new(), font_objects: HashMap::new(), region_resolved: HashMap::new(), @@ -722,6 +740,9 @@ impl Model { session_requests: Vec::new(), pvp_toggles: 0, sound_queue: Vec::new(), + cvars: HashMap::new(), + cvar_changes: Vec::new(), + cvars_warned: HashSet::new(), actions: HashMap::new(), action_states: HashMap::new(), bonus_bar_offset: 0, diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 529eaea4..93212177 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -231,9 +231,11 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Value::String(s) if s.to_str()?.is_empty() => { data.texture = None; data.color = None; + data.atlas = None; } Value::String(s) => { data.texture = Some(s.to_str()?.to_string()); + data.atlas = None; } Value::Number(_) | Value::Integer(_) => { data.color = Some([ @@ -248,6 +250,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Value::Nil => { data.texture = None; data.color = None; + data.atlas = None; } _ => {} } @@ -271,6 +274,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { data.texture = Some(path); data.circular = true; data.portrait_unit = None; + data.atlas = None; Ok(()) })?, )?; @@ -404,7 +408,15 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { let rh = region_handle_of(lua, this)?; let model = lua.app_data_ref::().expect("model"); let d = model.region_data.get(&rh); - let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key())); + // The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same + // recipe the request loop stamps, or every read under a SetScale'd owner reports stale. + let scale = model + .arena + .region(rh) + .and_then(|r| model.arena.frame(r.owner)) + .map(|f| f.effective_scale) + .unwrap_or(1.0); + let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key(scale))); let size = d.and_then(|d| d.size); let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0); let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0); @@ -628,6 +640,54 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Ok((l, r, t, b)) })?, )?; + // SetAtlas(name[, useAtlasSize]) — the Era atlas resolve (decision 0950): one call sets the + // sheet texture + UV sub-rect from the app-pushed table ([`super::UiScript::set_era_atlases`], + // baked offline from the UiTextureAtlas DB2s by scripts/era-extract.py), and with + // useAtlasSize the member's nominal size too. The reference's SetAtlas hard-errors on an + // unknown name; ours blanks the region and records a warn-once miss instead + // ([`super::UiScript::take_era_atlas_misses`]) — a stale extraction must not kill the whole + // XML load, but it must name itself. + m.set( + "SetAtlas", + lua.create_function( + |lua, (this, name, use_size): (Table, String, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let key = name.to_ascii_lowercase(); + let Some(entry) = model.era_atlas.get(&key).cloned() else { + model.era_atlas_missing.insert(key); + let d = model.region_data.entry(rh).or_default(); + d.texture = None; + d.atlas = None; + return Ok(()); + }; + let d = model.region_data.entry(rh).or_default(); + d.circular = false; + d.portrait_unit = None; + d.texture = Some(entry.file); + d.tex_coords = Some(TexCoords::Rect(entry.uv)); + d.atlas = Some(key); + if use_size.unwrap_or(false) { + let new = Some((entry.size[0], entry.size[1])); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } + } + Ok(()) + }, + )?, + )?; + // GetAtlas() → the member name the last SetAtlas applied (nil for an ordinary texture). + m.set( + "GetAtlas", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model.region_data.get(&rh).and_then(|d| d.atlas.clone())) + })?, + )?; // SetFontObject("GameFontNormal") — re-point this FontString at a named virtual Font object: its // resolved paint (face/height/color/outline) is copied into the region as the new default. An // explicit SetFont/SetTextColor afterward overrides. An unknown name errors (never silently no-op). diff --git a/crates/benilla-ui/src/script/tests/measure.rs b/crates/benilla-ui/src/script/tests/measure.rs index 67b52a2f..d4301b77 100644 --- a/crates/benilla-ui/src/script/tests/measure.rs +++ b/crates/benilla-ui/src/script/tests/measure.rs @@ -168,3 +168,105 @@ fn zero_width_fontstring_autosizes_to_its_line() { assert_eq!(v_left, 119.0); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// The frame-scale seam (0219 §2's divergence, closed): a `SetScale`'d frame's quads carry its +/// `effective_scale` out to the renderer — the rect is already scale-multiplied by layout, and +/// the renderer needs the same factor for the GLYPH raster size — and the measure round-trip +/// rides it too: the request names the scale (the host measures at the drawn size), the cache +/// key holds it (a re-scale re-measures), all before any quad draws stale text. +#[test] +fn frame_scale_rides_the_quads_and_the_measure_key() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local w = CreateFrame("Frame", "Win") + w:SetPoint("TOPLEFT", 0, -100); w:SetSize(400, 300) + w:SetScale(0.8) + local t = w:CreateFontString("ScaledLabel", "ARTWORK") + t:SetText("Options") + t:SetPoint("TOPLEFT", 10, -10) + "#, + ) + .unwrap(); + s.resolve(); + // The measure request carries the owner's effective scale. + let reqs = s.fontstrings_needing_measure(); + assert_eq!(reqs.len(), 1); + let r = &reqs[0]; + assert_eq!(r.scale, 0.8, "request names the drawn-size scale"); + let old_key = r.key; + let (id, key) = (r.id, r.key); + s.set_measured_text(&[(id, 50.0, 16.0, key)]); + s.resolve(); + assert!(s.fontstrings_needing_measure().is_empty(), "cache warm"); + // Every quad of the scaled frame carries the scale — frame slot and region alike. + let quads = s.extract(); + let label = quads + .iter() + .find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Options")) + .expect("label quad"); + assert_eq!(label.scale, 0.8); + // The label's rect is scale-multiplied by layout (width 50 × 0.8 = 40) — the quad's scale is + // for the glyph raster, not a second rect multiply. + let lr = label.rect.expect("label rect"); + assert!((lr.width() - 40.0).abs() < 0.01, "width {}", lr.width()); + // A re-scale invalidates the measure key: the same text re-measures at the new drawn size. + s.run(r#"Win:SetScale(1.25)"#).unwrap(); + s.resolve(); + let reqs = s.fontstrings_needing_measure(); + assert_eq!(reqs.len(), 1, "SetScale re-measures"); + assert_eq!(reqs[0].scale, 1.25); + assert_ne!(reqs[0].key, old_key, "key carries the scale"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The host's raster-environment invalidation ([`UiScript::invalidate_text_measures`]): a warm +/// FontString measure cache re-requests after the call — same content, same key (the key hashes +/// content, not the host's seam scale; the STALENESS is the host's to declare, which is the whole +/// seam). This is the engine half of the fullscreen-truncation fix: measures answered under one +/// seam scale kept satisfying fit tests run under another, and the ellipsis ate fitting text +/// ("Contr...", director 2026-08-04; reproduced end-to-end by `WOW_RESIZE`). +#[test] +fn invalidate_text_measures_reopens_the_round_trip() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local w = CreateFrame("Frame", "Win") + w:SetPoint("TOPLEFT", 0, -100); w:SetSize(400, 300) + local t = w:CreateFontString("Label", "ARTWORK") + t:SetText("Keybindings") + t:SetPoint("TOPLEFT", 10, -10) + "#, + ) + .unwrap(); + s.resolve(); + let reqs = s.fontstrings_needing_measure(); + assert_eq!(reqs.len(), 1); + let (id, key) = (reqs[0].id, reqs[0].key); + s.set_measured_text(&[(id, 74.0, 12.0, key)]); + s.resolve(); + assert!(s.fontstrings_needing_measure().is_empty(), "cache warm"); + assert_eq!( + s.eval::("return Label:GetStringWidth()").unwrap(), + 74.0 + ); + + s.invalidate_text_measures(); + // The measured extent is gone (GetStringWidth back to its unmeasured 0)… + assert_eq!( + s.eval::("return Label:GetStringWidth()").unwrap(), + 0.0, + "stale measure dropped, not served" + ); + // …and the round-trip reopens with the SAME content key — the request is the host's cue to + // re-measure under its new environment; nothing about the region itself changed. + let reqs = s.fontstrings_needing_measure(); + assert_eq!(reqs.len(), 1, "re-requests after invalidation"); + assert_eq!( + reqs[0].key, key, + "content key unchanged — only the answer was stale" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 4f2b412e..dfe16b1f 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -287,6 +287,25 @@ pub struct ExtractedQuad { /// frame is a ScrollFrame's scroll child, or any descendant of one — nested ScrollFrames /// intersect (see [`UiScript::extract`](super::UiScript::extract)'s `effective_clip`). `None` = unclipped, the common case. pub clip: Option, + /// The owning frame's `effective_scale` (`propagation.md`: `parentScale · ownScale`). The + /// `rect` above already carries it — the layout solver multiplies every placement by the + /// owner's scale — but glyph METRICS don't live in the rect: the renderer must rasterize a + /// `Text` quad's font at `font_height × scale` (and scale the shadow offset with it) or a + /// `SetScale`'d window draws scaled boxes full of unscaled text, the exact divergence + /// decision 0219 §2 recorded. 1.0 for the unscaled common case. + pub scale: f32, +} + +/// One Era atlas member (decision 0950): the resolved paint `SetAtlas`/`atlas=` apply. The app +/// builds these from `WoW-era/_extracted_ui/manifest.json` — `file` is an app-resolvable texture +/// path (the `era:` scheme), `uv` the normalized `[left, right, top, bottom]` sub-rect +/// ([`TexCoords::Rect`] order), `size` the member's nominal draw size in UI units (what +/// `useAtlasSize` applies). +#[derive(Clone, Debug, PartialEq)] +pub struct EraAtlasEntry { + pub file: String, + pub uv: [f32; 4], + pub size: [f32; 2], } /// The visual state of a region leaf (`Texture`/`FontString`), stored beside the arena because @@ -315,6 +334,10 @@ pub(crate) struct RegionData { pub(crate) alpha: Option, /// A texture path (`SetTexture("Interface\\...")`). pub(crate) texture: Option, + /// The Era atlas member this texture was last set from (`SetAtlas`, decision 0950) — what + /// `GetAtlas` reports. Bookkeeping only: `texture`/`tex_coords`/`size` carry the resolved + /// paint; any later `SetTexture` clears it (an ordinary region again). + pub(crate) atlas: Option, /// A color: a solid fill (`SetTexture(r,g,b,a)`) or a vertex tint (`SetVertexColor`). pub(crate) color: Option<[f32; 4]>, /// This region is a **portrait**: draw its texture masked to the inscribed circle (set by @@ -406,7 +429,10 @@ impl RegionData { /// insets on it). The layout solver's own read (layout.rs) deliberately does NOT key-check: /// it keeps the last-known box until the fresh measure lands, so lines never collapse for the /// round-trip frame. - pub(crate) fn measure_key(&self) -> u64 { + /// `scale` is the owner frame's `effective_scale`: it's in the key because the host measures + /// at the drawn raster size ([`MeasureRequest::scale`]), so a `SetScale` under a cached + /// measure must invalidate it exactly like a font-size change. + pub(crate) fn measure_key(&self, scale: f32) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); self.text.clone().unwrap_or_default().hash(&mut hasher); @@ -418,6 +444,7 @@ impl RegionData { // Outline is in the key because it changes measured WIDTH under the client's step law // (an outlined font steps +1px more per glyph — GlyphStepBase 0x5ca2b0). (self.outline as u8).hash(&mut hasher); + scale.to_bits().hash(&mut hasher); hasher.finish() } } @@ -437,6 +464,12 @@ pub struct EditBoxAdvanceRequest { /// The resolved glyph outline — the per-glyph step bias (+1px, GlyphStepBase 0x5ca2b0) /// changes every advance. pub outline: Outline, + /// The box frame's `effective_scale`. The host measures at the drawn raster size + /// (`height × scale × seam`) but divides the answer by the SEAM ALONE, so the advances come + /// back in screen UI units — the space the engine's ÷seam mouse feed and the box's resolved + /// (scale-multiplied) rect both live in. Contrast [`MeasureRequest::scale`], whose answer is + /// frame-local. + pub scale: f32, /// The DISPLAY string (mask-aware) the table indexes. pub text: String, /// `Some(width)` for a multiline box: also answer the wrapped-row starts + row pitch at this @@ -499,6 +532,12 @@ pub struct MeasureRequest { /// The resolved glyph outline — it biases the client's per-glyph step (+1px, GlyphStepBase /// 0x5ca2b0), so the measured width depends on it. pub outline: Outline, + /// The owner frame's `effective_scale`. The host must measure at the DRAWN raster size — + /// `font_height × scale × its own screen seam` — and divide the px answer back by the full + /// product: glyph advances step to whole pixels (the step law), so a measure taken at the + /// unscaled size is not `scale ×` the drawn one, and an auto-sized FontString in a scaled + /// frame would mis-fit its own text (spurious wrap → spurious "..." ellipsis). + pub scale: f32, pub text: String, /// Cache key — pass back verbatim. pub key: u64, @@ -520,10 +559,14 @@ pub struct LineMeasureRequest { pub index: u32, pub font: Option, pub height: Option, - /// The frame's resolved inner width — the wrap constraint. + /// The frame's resolved inner width — the wrap constraint. Already scale-multiplied (it is + /// the resolved rect's width), unlike `height`, which is the font's local size. pub wrap_width: f32, /// The resolved glyph outline (step-law width bias, as on [`MeasureRequest`]). pub outline: Outline, + /// The frame's `effective_scale` — the host wraps at the drawn raster size + /// (`height × scale × seam`), as on [`MeasureRequest::scale`]. + pub scale: f32, pub text: String, /// Cache key — pass back verbatim. pub key: u64, diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index b68dd929..d196aadf 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -50,6 +50,12 @@ crossbeam-channel = "0.5" benilla-assets = { path = "../benilla-assets" } benilla-formats = { path = "../benilla-formats" } benilla-protocol = { path = "../benilla-protocol" } +# The Era atlas manifest (decision 0950): `WoW-era/_extracted_ui/manifest.json`, written offline by +# scripts/era-extract.py and read once at boot. Both already in the tree (benilla-assets carries +# serde; serde_json rides Bevy transitively) — declared so the call site is an honest dependency. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" # ECS-native physics: collider storage + broadphase BVH + shape-casts, with a thin custom kinematic # character controller on top (decision 0009). Pinned 0.6: it targets Bevy 0.18 (verified single, # unified bevy 0.18.1 in the tree); 0.7.0 jumps to Bevy 0.19 and would duplicate bevy. avian wraps diff --git a/crates/benilla/assets/ui/GameMenuFrame.xml b/crates/benilla/assets/ui/GameMenuFrame.xml index ae857099..d3576b72 100644 --- a/crates/benilla/assets/ui/GameMenuFrame.xml +++ b/crates/benilla/assets/ui/GameMenuFrame.xml @@ -37,28 +37,47 @@ and `GameMenuFrame` are Era API surface an addon addresses by name. SCOPE / divergences (noted, not bugs): - - **Five buttons ship DISABLED**: Video/Sound/Interface Options, Key Bindings, Macros have no - panel behind them and won't for a long time. The director's call (this session) is that they - stay in the ladder — the frame keeps the ref's exact 195x246 and its exact silhouette — wearing - the template's own `-Disabled` art and grey label rather than being cut or left as clickable - no-ops. Each goes live by deleting one `self:Disable()` and writing the ref OnClick that is - already quoted beside it. (MicroMenu.xml made the opposite call for its dead icons, and says - why: three grey icons mid-row read as a broken bar. A menu of labels with some greyed is the - ordinary idiom — it reads as "not yet", which is the truth.) + - **The ladder is the ERA menu's** (director's call on the 0951 review — supersedes 0951 + §3's five-button collapse): the Era client's own ESC menu shape, greyed where benilla + has nothing behind the entry yet. Geometry quoted from the era dump @ 1.15.9 (Gethe + wow-ui-source, classic_era, the 0950 pin): + · Blizzard_SharedXML/Classic/Frame/MainMenuFrameTemplates.xml l.18-33 — the menu is a + VerticalLayoutFrame, buttonTemplate MainMenuFrameButtonTemplate, padding + top/bottom/left/right = 32/28/28/28, spacing 0; l.11-16 — the button is a 144x21 + UIPanelButtonTemplate with the white/white/grey label trio (the SAME art + trio the + 1.12 GameMenuButtonTemplate carries — no era-only art in this menu at all). + · Shared/Frame/MainMenuFrameTemplates.lua l.61-71 — AddSection() puts a 20-unit gap + (the next button's topPadding) between groups; AddCloseButton uses the same 20. + · Shared/GameMenuFrame.lua l.62-162 — the Era ladder: Options · [gap] · AddOns, Edit + Mode, Support, Macros · [gap] · Logout, Exit Game · [gap] · Return to Game. (Store / + Rewards / What's New / Ratings are structurally off in Era — store disabled, no + account store, IsStandard() false, koKR only.) + · The layout math those constants produce, precomputed here (this engine has no layout + frames): width 28+144+28 = 200; button tops 32/73/94/115/136/177/198/239; height + 239+21+28 = 288. Anchored as a TOPLEFT chain so the gaps read in the XML. + · The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate): + the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to + GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's + TOP+12 texture / era's 39-tall frame with the art centered) seat the art within half + a unit of each other; the 1.12 transcription is kept, the font and string are era's. + - **AddOns / Edit Mode / Support / Macros ship DISABLED** — nothing behind them yet + (`GameMenuButton_Pending`): the template's own `-Disabled` art + grey label, exactly + how the era menu greys a dead entry (SetEnabled(false) → DisabledFont). Each goes live + by deleting one `GameMenuButton_Pending` call and writing its OnClick. + - The Options label stays a literal "Options": 1.12 GlobalStrings has no era + GAMEMENU_OPTIONS (checked; its OPTIONS_MENU is "Options Menu", a different string). - The ref frame is `toplevel="true" movable="true" parent="UIParent"`. Dropped: this engine has no cross-file `parent=` attachment (SkillFrame.xml's header comment), no frame-level raise, and `movable` is inert in the ref too (GameMenuFrame defines no drag scripts, so it never actually moves). `frameStrata="DIALOG"` is what puts it over the world UI, and that is kept. - - No `OptionsFrameCancel` rung in ToggleGameMenu (UiPanels.xml) — no OptionsFrame exists. --> - + + + + + + - + + @@ -168,7 +199,10 @@ - + + @@ -176,40 +210,58 @@ - + + + - - - - - - - @@ -220,7 +272,7 @@ re-read that state. --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getglobal(self:GetName() .. "Label"):SetText("Gameplay") + getglobal(self:GetName() .. "Bg"):SetAtlas("Options_CategoryHeader_1", true) + + + + + + + + + + + + + + + + + + getglobal(self:GetName() .. "Label"):SetText("System") + getglobal(self:GetName() .. "Bg"):SetAtlas("Options_CategoryHeader_2", true) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if this:GetText() == "" then + OptionsFrameSearchBoxInstructions:Show() + OptionsFrameSearchBoxClearButton:Hide() + else + OptionsFrameSearchBoxInstructions:Hide() + OptionsFrameSearchBoxClearButton:Show() + end + + this:ClearFocus() + + + + + + + + + + + + + + OptionsFrame_UpdateScale() + OptionsFrame_SelectCategory(this.selectedCategory or "Controls") + + + + diff --git a/crates/benilla/assets/ui/UiPanels.xml b/crates/benilla/assets/ui/UiPanels.xml index 6a99dbe0..c11a1cb1 100644 --- a/crates/benilla/assets/ui/UiPanels.xml +++ b/crates/benilla/assets/ui/UiPanels.xml @@ -89,6 +89,13 @@ -- every non-center panel (`CanOpenPanels` below). That is the rule behind "you can't open -- anything with the ESC menu up". whileDead: dying is exactly when you want to log out. UIPanelWindows["GameMenuFrame"] = { area = "center", pushable = 0, whileDead = 1 } + -- The options window (decision 0950, OptionsFrame.xml) — the ref's own registration + -- verbatim (`UIPanelWindows["OptionsFrame"] = { area = "center", pushable = 0, + -- whileDead = 1 }`, UIParent.lua l.16): the second native-center row. Same MODE as the + -- menu above — it takes the screen, and dying is exactly when you might want to retune + -- the client. (The ref's l.17-18 register SoundOptionsFrame/UIOptionsFrame the same way; + -- those panels are this one window's category rows now, so the rows have no frames.) + UIPanelWindows["OptionsFrame"] = { area = "center", pushable = 0, whileDead = 1 } -- The world map (decision 0203 phase 2) — the ref's own line verbatim (UIParent.lua l.30) -- and the first **full** row. It was a plain toplevel until now, showing itself with a bare -- `WorldMapFrame:Show()` and so bypassing this manager entirely: with the game menu up it @@ -495,8 +502,9 @@ -- cancels the cast and ONLY the cast; the target drops on a later press once nothing -- else claims it; and only a press with NOTHING left to eat opens the menu. -- - -- Absent branches, and why: no OptionsFrame (nothing to cancel — GameMenuFrame.xml's five - -- disabled buttons are exactly that gap). ONE benilla ADDITION, ahead of everything: a held cursor item is + -- Every reference branch is present now (the OptionsFrame rung landed with decision + -- 0950 — ours hides rather than cancel-clicks; its own comment below says why). ONE + -- benilla ADDITION, ahead of everything: a held cursor item is -- dropped (ClearCursor). The cursor-item model (decision 0068 T2) has no real-client ESC -- handling to mirror — vanilla leaves the item on the cursor — so this is a deliberate -- benilla convenience (ESC as the universal "get me out" key). Guarded so it stays inert @@ -509,8 +517,13 @@ -- abort the whole chain there (the `CloseMenus and` guard two rungs down, same reason). local menu = getglobal("GameMenuFrame") - -- The micro button's toggle (l.1466-1480). + -- The micro button's toggle (l.1466-1480). Its own first move is the ref's too + -- (l.1467-1469): an open options window is put away before the menu toggles. if clicked then + local options = getglobal("OptionsFrame") + if options and options:IsVisible() then + HideUIPanel(options) + end if menu and menu:IsVisible() then PlaySound("igMainMenuQuit") HideUIPanel(menu) @@ -532,11 +545,21 @@ if StaticPopup_EscapePressed() then return end - -- The menu itself closes next (l.1485-1487) — before every close/cancel rung below, so - -- ESC with the menu up only puts the menu away. (The OptionsFrame rung the reference - -- checks between these two has no benilla frame; GameMenuFrame.xml's disabled buttons - -- ARE that gap.) Note the sound: closing by ESC plays igMainMenuQuit, the same kit the - -- Exit Game button uses — the reference's choice, not a copy/paste slip here. + -- The options window next (l.1483-1484, live since decision 0950 — this rung sat + -- unfilled while GameMenuFrame.xml's disabled buttons were the gap). The ref + -- cancel-clicks (`OptionsFrameCancel:Click()`); ours hides: benilla applies changes + -- live, there is no pending-change model for a Cancel to roll back, so putting the + -- window away IS the whole gesture. Looked up like the menu above (a headless panel + -- test loads this file without OptionsFrame.xml behind it). + local options = getglobal("OptionsFrame") + if options and options:IsVisible() then + HideUIPanel(options) + return + end + -- The menu itself closes next (l.1485-1487) — before every close/cancel rung below, + -- so ESC with the menu up only puts the menu away. Note the sound: closing by ESC + -- plays igMainMenuQuit, the same kit the Exit Game button uses — the reference's + -- choice, not a copy/paste slip here. if menu and menu:IsVisible() then PlaySound("igMainMenuQuit") HideUIPanel(menu) diff --git a/crates/benilla/src/assets/era.rs b/crates/benilla/src/assets/era.rs new file mode 100644 index 00000000..289be16c --- /dev/null +++ b/crates/benilla/src/assets/era.rs @@ -0,0 +1,87 @@ +//! The app side of the Era atlas seam (decision 0950): read the extractor's manifest once at +//! boot and turn it into the [`UiScript`](benilla_ui::script::UiScript) atlas table, whose +//! entries carry `era:`-scheme texture paths that [`super::WorldAssets::sprite_texture`] +//! resolves from disk. Runtime never touches CASC or DB2 — `scripts/era-extract.py` baked the +//! `UiTextureAtlas` indirection offline into `WoW-era/_extracted_ui/{manifest.json, +//! textures/.blp}`; a missing or stale extraction WARNs and draws blank, never fails the +//! load (the misses name themselves through `take_era_atlas_misses`). + +use benilla_ui::script::EraAtlasEntry; +use serde::Deserialize; + +/// The extracted Era UI assets root — through the `WoW-era` symlink `wt.sh link_wow` provisions +/// in every slot (same `CARGO_MANIFEST_DIR` convention as the MPQ `Data` default elsewhere in +/// this crate: the path follows the worktree that built the binary). +pub(crate) fn era_ui_root() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW-era/_extracted_ui") +} + +/// `manifest.json`, exactly as `scripts/era-extract.py` writes it (the fields we consume). +#[derive(Deserialize)] +struct Manifest { + build: String, + atlases: std::collections::HashMap, +} + +#[derive(Deserialize)] +struct Member { + /// Relative to the extraction root (`textures/.blp`). + file: String, + /// The sheet's dimensions in pixels. + atlas_size: [f32; 2], + /// Pixel edges `[left, top, right, bottom)` within the sheet. + rect: [f32; 4], + /// Nominal draw size in UI units (`useAtlasSize`). + size: [f32; 2], +} + +/// Load and normalize the manifest into UiScript table entries: pixel rects become the +/// `[left, right, top, bottom]` UV form `TexCoords::Rect` stores; files ride the `era:` scheme. +/// `None` = no usable manifest (absent install, extraction never run, or a parse error — each +/// WARNs with the fix spelled out; the distinction matters when diagnosing a blank window). +pub(crate) fn load_era_atlases() -> Option> { + let path = era_ui_root().join("manifest.json"); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + bevy::log::warn!( + "era: no UI manifest at {} ({e}) — Era-styled windows will draw blank; \ + run scripts/era-extract.py", + path.display() + ); + return None; + } + }; + let manifest: Manifest = match serde_json::from_slice(&bytes) { + Ok(m) => m, + Err(e) => { + bevy::log::warn!( + "era: manifest.json unparseable ({e}) — re-run scripts/era-extract.py" + ); + return None; + } + }; + bevy::log::debug!( + "era: atlas manifest for build {} — {} members", + manifest.build, + manifest.atlases.len() + ); + Some( + manifest + .atlases + .into_iter() + .map(|(name, m)| { + let [aw, ah] = m.atlas_size; + let [l, t, r, b] = m.rect; + ( + name, + EraAtlasEntry { + file: format!("era:{}", m.file), + uv: [l / aw, r / aw, t / ah, b / ah], + size: [m.size[0], m.size[1]], + }, + ) + }) + .collect(), + ) +} diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs index 0dbc0e0e..582b9121 100644 --- a/crates/benilla/src/assets/mod.rs +++ b/crates/benilla/src/assets/mod.rs @@ -27,6 +27,9 @@ use benilla_formats::{open_chain, read_texture_mip_chain, read_texture_rgba, Cha mod images; pub(crate) use images::*; +/// The Era atlas manifest side of decision 0950 (`era:` texture scheme + the UiScript table). +pub(crate) mod era; + /// Shared asset store: the **one** open patch chain plus dedup caches, used by every model/texture /// load (terrain layers, doodads, WMOs, creatures, GameObjects). Replaces the three independent /// chains we used to open. Deduping by path/material means each unique BLP is decoded + uploaded @@ -178,6 +181,23 @@ impl WorldAssets { path: &str, images: &mut Assets, ) -> Option> { + // `era:` (decision 0950): a loose extracted file under + // `WoW-era/_extracted_ui/`, never the MPQ chain — the scheme string is its own cache key + // (verbatim, case-sensitive: the manifest writes it, authors copy it). + if let Some(rel) = path.strip_prefix("era:") { + if let Some(cached) = self.sprites.get(path) { + return cached.clone(); + } + let loaded = std::fs::read(era::era_ui_root().join(rel)) + .ok() + .and_then(|bytes| benilla_formats::blp_to_rgba(&bytes).ok()) + .map(|(w, h, rgba)| images.add(sprite_image(w, h, rgba))); + if loaded.is_none() { + warn!("era: sprite '{path}' unreadable — stale extraction? run scripts/era-extract.py"); + } + self.sprites.insert(path.to_string(), loaded.clone()); + return loaded; + } // FrameXML/DBC texture references are canonically EXTENSIONLESS // (`Interface\Buttons\UI-Quickslot2`) — the real client appends `.blp` at resolve. Paths // that already carry an extension (`textures\moon.blp`) pass through unchanged. diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 422668c4..136e75b9 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -919,6 +919,18 @@ pub(super) fn seed_ui_fixture( selection.target = Some(wolf); selection.guid = Some(WOLF_GUID); } + UiFixture::Options => { + let Some(script) = script else { + return; + }; + // Static window, opened through the live panel path — nothing else to seed. What the + // capture pins: the era chrome (nine-slice seams, right-edge straddle), the tab + // plates, the search-box seat, the category list art with Controls selected (the + // OnShow default), and the window's fit scale. + if let Err(e) = script.run("ShowUIPanel(OptionsFrame)") { + warn!("capture: ui-options seed failed to open the window: {e}"); + } + } UiFixture::SpellBook => { // The director's own report reproduced (decision 0228): a HUMAN WARRIOR (race 1, // class 1) who learned Fireball + Mind Flay via a GM command during testing. The book diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index eaa2b014..206e998a 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -358,6 +358,8 @@ struct CaptureCtx { /// The frozen clock is in force ([`CAPTURE_FRAME_DT`]) — always, except under a perf probe, /// whose entire measurement *is* the real frame cost. frozen_clock: bool, + /// `$WOW_RESIZE=WxH` already applied (once, at first settle) — see the `Building` arm. + resized: bool, /// Probe samples (frame ms). probe_samples: Vec, /// Process CPU seconds at the first sampled frame — the window baseline for the probe line's @@ -365,6 +367,15 @@ struct CaptureCtx { probe_cpu_start: Option, } +/// `$WOW_RESIZE=WxH` — resize the window to this (logical px) once the image first settles, +/// then settle again before shooting. The mid-session resolution-change instrument (see the +/// `Building` arm). `None` when unset or malformed. +fn resize_request() -> Option<(u32, u32)> { + let v = std::env::var("WOW_RESIZE").ok()?; + let (w, h) = v.split_once('x')?; + Some((w.parse().ok()?, h.parse().ok()?)) +} + /// The present mode a perf probe uncaps to (also the live probe's — see `probes.rs`). /// /// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap — A/B'd @@ -587,6 +598,7 @@ impl Plugin for CapturePlugin { ui_seeded: false, probe_frames, frozen_clock, + resized: false, probe_samples: Vec::new(), probe_cpu_start: None, }) @@ -732,6 +744,20 @@ fn drive_capture( } if watch.stable < stable_frames() && !capped { Phase::Building(n + 1) + } else if let Some((rw, rh)) = resize_request().filter(|_| !ctx.resized) { + // `$WOW_RESIZE=WxH` (logical px): a mid-session resolution change, applied only + // once the image has SETTLED at the boot size — the whole UI has been built and + // its text measured under the boot scale before the window changes, which is + // exactly the fullscreen-toggle flow a fresh boot at the target size can't + // exercise (stale text-metric caches were invisible to every same-size capture). + // The stability watch restarts and the shot photographs the post-resize frame. + ctx.resized = true; + if let Ok(mut w) = windows.single_mut() { + w.resolution.set(rw as f32, rh as f32); + } + watch.stable = 0; + info!("capture: resized to {rw}x{rh}, re-settling"); + Phase::Building(0) } else if fixture_age.is_some() { if let Some(state) = fx_state.as_deref_mut() { state.armed = true; // scene ready — the fixture spawns now, age clock clean diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index d75b4e07..fdd55d4b 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -88,6 +88,11 @@ pub(super) enum UiFixture { /// capture checks the header (left-flush, Say-white), the typed text past the live insets, /// and the three-piece input border. ChatEdit, + /// The era-styled Options window (decisions 0950/0951), opened through the live panel path — + /// the look-pass instrument for the whole options arc: chrome nine-slice seams, tab plates, + /// search-box seat, category list art, the window scale. Static (no server state touched), + /// so its pixels move only when the window or the atlas seam does. + Options, /// A **floating overhead name with the river surface behind it** — the world-text-vs-liquid /// draw-order instrument. A named unit stands 25 yd out in the Elwynn river (the `water-noon` /// camera), so its name projects onto the water *beyond* it: the exact geometry of the @@ -811,6 +816,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::ChatEdit), }, + // The era Options window over the ground scene. Run with + // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`. + Scenario { + name: "ui-options", + map: MAP_AZEROTH, + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Options), + }, // An overhead NAME with the river surface behind it — the world-text-vs-liquid draw-order // instrument (see [`UiFixture::NameWater`]). Same camera as `water-noon`, plus a named unit // out in the water. Run with `WOW_CAPTURE=name-water`. diff --git a/crates/benilla/src/cvars.rs b/crates/benilla/src/cvars.rs new file mode 100644 index 00000000..617da869 --- /dev/null +++ b/crates/benilla/src/cvars.rs @@ -0,0 +1,431 @@ +//! benilla's **CVar host** — registration, knob sync, and the client's first persistence +//! (decision 0954). The engine holds the table and the Lua API ([`benilla_ui::script`]'s +//! `GetCVar`/`SetCVar`); this module is everything host-side: +//! +//! - **The registered set** ([`REGISTERED`]): only vars backed by a real engine knob (the +//! honest-tree rule). Defaults are the code's own truths — the sound quartet is the client's +//! verified CVar registration defaults (wow-re `benilla-pins.md` B10, quoted in +//! [`crate::sound::SoundConfig`]), `uiScale`/`farclip` are benilla's shipped defaults — +//! and a test welds each string to the constant it mirrors so they cannot drift. +//! - **Boot**: read `benilla/config.toml` ([`crate::local_state`]) and apply it to the knob +//! resources; when the UI VM exists, register the table and push the resolved session values +//! so `GetCVar` answers what the client is actually doing. +//! - **Sync**: drain Lua `SetCVar` changes into the knob resources each frame and mark the +//! config dirty. +//! - **Save**: dirty + one quiet second → rewrite `config.toml` atomically (and flush on +//! `AppExit`). The file holds **only values that moved off their default** — a diff, not a +//! dump — plus any entries this build doesn't know (a newer build's keys survive a downgrade; +//! same posture for a hand-added key: preserved verbatim, warned once). +//! +//! **Env overrides win for the session and never touch the file**: `WOW_UI_SCALE`/`WOW_FARCLIP` +//! beat the loaded config (they exist to make taste iteration a relaunch — pinning one into the +//! config would make the A/B sticky), the session runs and saves around them, and the file keeps +//! whatever it already said for those keys. + +use std::collections::{BTreeMap, HashSet}; +use std::time::Instant; + +use bevy::prelude::*; + +use crate::sound::SoundConfig; +use crate::ui_script::UiScaleCvar; +use crate::view::{ViewDistance, FARCLIP_RANGE}; +use benilla_ui::script::UiScript; + +/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page +/// actually wires — never ahead of the knob (see the module doc). +pub(crate) const REGISTERED: &[(&str, &str)] = &[ + ("MasterVolume", "1"), + ("SoundVolume", "1"), + ("MusicVolume", "0.4"), + ("AmbienceVolume", "0.6"), + ("uiScale", "0.9"), + ("farclip", "777"), +]; + +/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in +/// the reference too; consumers parse and clamp at their edge). BTreeMap so the file is stably +/// sorted on every save. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct LocalConfig { + #[serde(default)] + cvars: BTreeMap, +} + +/// The persistence state: what the file said, which keys the environment overrides this +/// session, and the dirty/debounce pair. +#[derive(Resource, Default)] +pub(crate) struct CvarPersist { + /// The file's `[cvars]` entries, verbatim spelling — the merge base every save starts from + /// (unknown keys ride through untouched, env-overridden keys keep their stored value). + file: BTreeMap, + /// Lowercased names whose value came from an env var this session (never saved). + env_overridden: HashSet, + /// The engine table has been registered + seeded (once, when the VM first exists). + registered: bool, + /// A change since the last save; `last_change` drives the one-quiet-second debounce. + dirty: bool, + last_change: Option, +} + +/// How long a dirty config sits before the save fires — long enough to coalesce a slider drag, +/// short enough that a crash loses one gesture, not a session ("write-on-change, debounced"). +const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1); + +pub(crate) struct CvarPlugin; + +impl Plugin for CvarPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, load_config) + .add_systems(Update, (sync_cvars, save_config).chain()); + } +} + +/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this +/// build knows (the caller decides whether that warns or rides through). +fn apply_to_knobs( + name: &str, + value: &str, + sound: &mut SoundConfig, + scale: &mut UiScaleCvar, + view: &mut ViewDistance, +) -> bool { + let Ok(v) = value.parse::() else { + warn!("cvar {name}: unparseable value '{value}' ignored"); + return true; // known key, bad value — consumed, resource keeps its truth + }; + match name.to_ascii_lowercase().as_str() { + "mastervolume" => sound.master = v.clamp(0.0, 1.0), + "soundvolume" => sound.sfx = v.clamp(0.0, 1.0), + "musicvolume" => sound.music = v.clamp(0.0, 1.0), + "ambiencevolume" => sound.ambience = v.clamp(0.0, 1.0), + "uiscale" => scale.0 = v.clamp(0.5, 1.5), + "farclip" => view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), + _ => return false, + } + true +} + +/// Startup: read `benilla/config.toml` (absent file = all defaults, not an error) and apply it +/// to the knob resources — except keys the environment overrides this session (their resources +/// already read the env var in their `Default`s). The VM does not exist yet; [`sync_cvars`] +/// seeds the table when it does. +fn load_config( + mut persist: ResMut, + mut sound: ResMut, + mut scale: ResMut, + mut view: ResMut, +) { + if std::env::var_os("WOW_UI_SCALE").is_some() { + persist.env_overridden.insert("uiscale".into()); + } + if std::env::var_os("WOW_FARCLIP").is_some() { + persist.env_overridden.insert("farclip".into()); + } + let Some(path) = crate::local_state::config_path() else { + return; // hermetic capture, or no install — session-only state + }; + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + warn!("config: cannot read {}: {e}", path.display()); + return; + } + }; + let cfg: LocalConfig = match toml::from_str(&text) { + Ok(c) => c, + Err(e) => { + // A malformed file is preserved, not clobbered: nothing loads, but nothing saves + // over it either until a change actually happens — and the warn names the file. + warn!( + "config: {} is malformed ({e}) — running on defaults", + path.display() + ); + return; + } + }; + let known: HashSet = REGISTERED + .iter() + .map(|(n, _)| n.to_ascii_lowercase()) + .collect(); + for (name, value) in &cfg.cvars { + let key = name.to_ascii_lowercase(); + if !known.contains(&key) { + warn!("config: unknown cvar '{name}' — preserved, not applied"); + continue; + } + if persist.env_overridden.contains(&key) { + info!("config: {name} overridden by env for this session (file value kept)"); + continue; + } + apply_to_knobs(name, value, &mut sound, &mut scale, &mut view); + } + persist.file = cfg.cvars; +} + +/// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values, +/// so `GetCVar` reflects env overrides and the loaded config alike), then drain Lua `SetCVar` +/// changes into the knob resources and mark the config dirty. +fn sync_cvars( + script: Option>, + mut persist: ResMut, + mut sound: ResMut, + mut scale: ResMut, + mut view: ResMut, +) { + let Some(mut script) = script else { + return; + }; + if !persist.registered { + script.register_cvars(REGISTERED.iter().copied()); + let session: [(&str, String); 6] = [ + ("MasterVolume", sound.master.to_string()), + ("SoundVolume", sound.sfx.to_string()), + ("MusicVolume", sound.music.to_string()), + ("AmbienceVolume", sound.ambience.to_string()), + ("uiScale", scale.0.to_string()), + ("farclip", view.farclip.to_string()), + ]; + for (name, value) in session { + script.set_cvar_host(name, &value); + } + persist.registered = true; + } + for (name, value) in script.take_cvar_changes() { + if apply_to_knobs(&name, &value, &mut sound, &mut scale, &mut view) { + persist.dirty = true; + persist.last_change = Some(Instant::now()); + } + } +} + +/// Compose the file to save: the previous file as the merge base, every registered var that +/// moved off its default written, every one back at its default removed — env-overridden keys +/// untouched (the session value is the env's, not the player's). +fn compose_file( + previous: &BTreeMap, + env_overridden: &HashSet, + snapshot: &[(String, String, String)], +) -> BTreeMap { + let mut out = previous.clone(); + for (name, value, default) in snapshot { + let key = name.to_ascii_lowercase(); + if env_overridden.contains(&key) { + continue; + } + // Match any existing entry case-insensitively so a hand-edited spelling doesn't fork. + let existing = out.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned(); + if value == default { + if let Some(k) = existing { + out.remove(&k); + } + } else { + out.insert(existing.unwrap_or_else(|| name.clone()), value.clone()); + } + } + out +} + +/// The file's header comment — where these values come from and where the law lives. +const HEADER: &str = "\ +# benilla local config (decision 0954) — CVar values that moved off their defaults. +# Managed by the client; hand edits are read on next launch and preserved on save. +"; + +/// Dirty + one quiet second (or the app exiting) → rewrite `config.toml` atomically. +fn save_config( + script: Option>, + mut persist: ResMut, + mut exits: MessageReader, +) { + let exiting = exits.read().next().is_some(); + if !persist.dirty { + return; + } + let quiet = persist + .last_change + .is_none_or(|t| t.elapsed() >= SAVE_QUIET); + if !(quiet || exiting) { + return; + } + let Some(script) = script else { return }; + let Some(path) = crate::local_state::config_path() else { + persist.dirty = false; // hermetic/session-only: nothing to write, stop retrying + return; + }; + let snapshot = script.cvars_snapshot(); + let cvars = compose_file(&persist.file, &persist.env_overridden, &snapshot); + let body = toml::to_string(&LocalConfig { + cvars: cvars.clone(), + }) + .expect("string map serializes"); + match crate::local_state::write_atomic(&path, &format!("{HEADER}{body}")) { + Ok(()) => { + persist.file = cvars; + persist.dirty = false; + } + Err(e) => { + warn!("config: cannot write {}: {e}", path.display()); + persist.dirty = false; // don't retry every frame into the same error + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_script::DEFAULT_UI_SCALE; + + /// Every registered default IS the code constant it mirrors — parse-compared so "1" vs + /// "1.0" cannot fail it, welded so neither side can drift alone. + #[test] + fn registered_defaults_mirror_the_code_truths() { + let d: BTreeMap<&str, f32> = REGISTERED + .iter() + .map(|(n, v)| (*n, v.parse::().unwrap())) + .collect(); + let sound = SoundConfig::default(); + assert_eq!(d["MasterVolume"], sound.master); + assert_eq!(d["SoundVolume"], sound.sfx); + assert_eq!(d["MusicVolume"], sound.music); + assert_eq!(d["AmbienceVolume"], sound.ambience); + assert_eq!(d["uiScale"], DEFAULT_UI_SCALE); + // ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the + // env-less 777 literal (view.rs doc: "Default 777"). + assert_eq!(d["farclip"], 777.0); + } + + #[test] + fn apply_parses_clamps_and_reports_unknowns() { + let mut sound = SoundConfig::default(); + let mut scale = UiScaleCvar(0.9); + let mut view = ViewDistance { farclip: 777.0 }; + assert!(apply_to_knobs( + "MusicVolume", + "0.7", + &mut sound, + &mut scale, + &mut view + )); + assert_eq!(sound.music, 0.7); + // Clamps are the knob's own: volume to [0,1], farclip to FARCLIP_RANGE. + assert!(apply_to_knobs( + "mastervolume", + "7", + &mut sound, + &mut scale, + &mut view + )); + assert_eq!(sound.master, 1.0); + assert!(apply_to_knobs( + "farclip", "50", &mut sound, &mut scale, &mut view + )); + assert_eq!(view.farclip, *FARCLIP_RANGE.start()); + // A bad value is consumed (known key) and the resource keeps its truth. + assert!(apply_to_knobs( + "uiScale", "banana", &mut sound, &mut scale, &mut view + )); + assert_eq!(scale.0, 0.9); + assert!(!apply_to_knobs( + "bogus", "1", &mut sound, &mut scale, &mut view + )); + } + + #[test] + fn compose_writes_the_diff_and_preserves_what_it_does_not_own() { + let previous: BTreeMap = [ + ("FutureKnob".to_string(), "3".to_string()), // a newer build's key: preserved + ("uiScale".to_string(), "0.8".to_string()), // env-overridden this session + ("farclip".to_string(), "400".to_string()), // will return to default + ] + .into(); + let env: HashSet = ["uiscale".to_string()].into(); + let snapshot = vec![ + // (name, value, default) + ("MusicVolume".into(), "0.7".into(), "0.4".into()), // moved: written + ("MasterVolume".into(), "1".into(), "1".into()), // default: absent + ("uiScale".into(), "1.2".into(), "0.9".into()), // env value: file keeps 0.8 + ("farclip".into(), "777".into(), "777".into()), // back to default: removed + ]; + let out = compose_file(&previous, &env, &snapshot); + assert_eq!(out.get("MusicVolume").map(String::as_str), Some("0.7")); + assert!(!out.contains_key("MasterVolume")); + assert_eq!(out.get("uiScale").map(String::as_str), Some("0.8")); + assert!(!out.contains_key("farclip")); + assert_eq!(out.get("FutureKnob").map(String::as_str), Some("3")); + } + + /// End to end on a real App: a pre-written `config.toml` loads into the knobs at Startup, a + /// Lua `SetCVar` drains into the knobs and — on the exit flush — lands back in the file as a + /// diff (the moved value present, the untouched ones absent). This is the whole 0954 slice-1 + /// loop in one place: file → knobs → VM table → Lua write → knobs → file. + #[test] + fn a_lua_setcvar_lands_in_config_toml_end_to_end() { + use crate::local_state::test_env::{EnvGuard, ENV_LOCK}; + let _l = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!("benilla-cvar-e2e-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _u = EnvGuard::unset("WOW_UI_SCALE"); + let _f = EnvGuard::unset("WOW_FARCLIP"); + let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + crate::local_state::write_atomic( + &tmp.join("config.toml"), + "[cvars]\nMusicVolume = \"0.1\"\n", + ) + .unwrap(); + + let mut app = App::new(); + app.add_plugins(bevy::MinimalPlugins) + .insert_resource(SoundConfig::default()) + .insert_resource(UiScaleCvar(DEFAULT_UI_SCALE)) + .insert_resource(ViewDistance { farclip: 777.0 }) + .add_plugins(CvarPlugin); + app.insert_non_send_resource(UiScript::new().unwrap()); + + // Startup: the file's MusicVolume reaches the knob; Update: the VM table seeds from it. + app.update(); + assert_eq!(app.world().resource::().music, 0.1); + assert_eq!( + app.world_mut() + .non_send_resource_mut::() + .cvar("MusicVolume") + .as_deref(), + Some("0.1") + ); + + // The Lua write (what a settings slider will do) reaches the knob on the next frame… + app.world_mut() + .non_send_resource_mut::() + .run(r#"SetCVar("MusicVolume", 0.75)"#) + .unwrap(); + app.update(); + assert_eq!(app.world().resource::().music, 0.75); + + // …and the exit flush writes the diff: the moved value, nothing at its default. + app.world_mut().write_message(AppExit::Success); + app.update(); + let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap(); + assert!(text.contains("MusicVolume = \"0.75\""), "{text}"); + assert!(!text.contains("MasterVolume"), "defaults stay out:\n{text}"); + let back: LocalConfig = toml::from_str(&text).unwrap(); + assert_eq!(back.cvars.len(), 1, "a diff, not a dump: {text}"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn the_toml_round_trips() { + let cfg = LocalConfig { + cvars: [("MusicVolume".to_string(), "0.7".to_string())].into(), + }; + let text = format!("{HEADER}{}", toml::to_string(&cfg).unwrap()); + let back: LocalConfig = toml::from_str(&text).unwrap(); + assert_eq!(back.cvars, cfg.cvars); + // The header survives as comments; a hand edit with comments parses too. + let hand = "# my note\n[cvars]\nFarclip = \"500\"\n"; + let parsed: LocalConfig = toml::from_str(hand).unwrap(); + assert_eq!(parsed.cvars.get("Farclip").map(String::as_str), Some("500")); + } +} diff --git a/crates/benilla/src/local_state.rs b/crates/benilla/src/local_state.rs new file mode 100644 index 00000000..66b9d095 --- /dev/null +++ b/crates/benilla/src/local_state.rs @@ -0,0 +1,150 @@ +//! Where benilla keeps **local state** — THE LAW (decision 0954). Every file benilla persists on +//! a player's machine — config today; realmlist, caches, per-character state as they arrive — +//! lives in **one visible folder, `benilla/`, beside the WoW install's own `Data/`**. Never +//! scattered into the install, never hidden in a platform config dir: the 1.12 client is itself +//! fully portable (`WTF/`, `realmlist.wtf`, `Cache/` all in-folder — verified on the live +//! install), its community expects the drop-in-a-folder shape, and one folder answers "what here +//! is benilla's?" at a glance (the director's call, 2026-08-04; prior art: Dolphin's +//! `portable.txt`, DREAMM's config-next-to-exe — we skip the marker file because benilla always +//! knows the install root from `$WOW_DATA`). +//! +//! **Every feature with local state resolves its paths through this module** — nothing else in +//! the tree may compute a persistence path (grep `local_state::` to see every resident). New +//! files get a name here and a line in decision 0954's layout table. +//! +//! Resolution, in order: +//! 1. **`$BENILLA_HOME`** — explicit override (tests point it at a tempdir; a second install or +//! a shared-config setup points it wherever it likes). +//! 2. **`/benilla/`** — the parent of `$WOW_DATA` (default `WoW/Data` → `WoW/ +//! benilla/`). Gated on the Data dir actually existing, so a dev run without an install never +//! plants a phantom folder in some working directory. +//! +//! **Capture/probe runs are hermetic**: with `$WOW_CAPTURE` set every path resolves to `None` — +//! a deterministic capture must not read one machine's saved settings or write anything back +//! (the harness owns its inputs; decision 0008's reproducibility posture). `None` is also the +//! honest answer when no install exists: features degrade to session-only state, warn once, and +//! keep running. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// The one benilla-state folder, or `None` when persistence is off (hermetic capture run, or no +/// resolvable install). Existence is NOT guaranteed — [`write_atomic`] creates it on first write; +/// readers just try their file and treat absent as defaults. +pub(crate) fn home() -> Option { + if std::env::var_os("WOW_CAPTURE").is_some() { + return None; // hermetic: captures neither read nor write player state + } + if let Some(over) = std::env::var_os("BENILLA_HOME") { + return Some(PathBuf::from(over)); + } + let data = PathBuf::from(std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into())); + if !data.is_dir() { + return None; // no install, nothing to sit beside — session-only state + } + Some(data.parent().unwrap_or(Path::new(".")).join("benilla")) +} + +/// `benilla/config.toml` — the CVar overrides (the `Config.wtf` analog; see `crate::cvars`). +pub(crate) fn config_path() -> Option { + home().map(|h| h.join("config.toml")) +} + +/// Write a state file atomically: parent dirs created, contents to `.tmp`, then rename — +/// a crash mid-write leaves the old file intact, never a truncated one. The one write path for +/// every resident of the folder. +pub(crate) fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(contents.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, path) +} + +/// Shared test plumbing for anything that toggles the persistence env vars: `std::env::set_var` +/// is process-global and cargo runs tests in threads, so every such test takes [`ENV_LOCK`] and +/// scopes its overrides in [`EnvGuard`]s (restore-on-drop). Used here and by `crate::cvars`'s +/// end-to-end test. +#[cfg(test)] +pub(crate) mod test_env { + pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + pub(crate) struct EnvGuard(&'static str, Option); + impl EnvGuard { + pub(crate) fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var_os(key); + std::env::set_var(key, value); + Self(key, old) + } + pub(crate) fn unset(key: &'static str) -> Self { + let old = std::env::var_os(key); + std::env::remove_var(key); + Self(key, old) + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.1 { + Some(v) => std::env::set_var(self.0, v), + None => std::env::remove_var(self.0), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::test_env::{EnvGuard, ENV_LOCK}; + use super::*; + + #[test] + fn the_home_law_override_then_install_root_and_hermetic_captures() { + let _l = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!("benilla-ls-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("Data")).unwrap(); + + // 2 · install root: home = parent-of-Data /benilla. + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _h = EnvGuard::unset("BENILLA_HOME"); + let _d = EnvGuard::set("WOW_DATA", tmp.join("Data").to_str().unwrap()); + assert_eq!(home(), Some(tmp.join("benilla"))); + assert_eq!(config_path(), Some(tmp.join("benilla/config.toml"))); + + // 1 · the explicit override wins over the install root. + let _h2 = EnvGuard::set("BENILLA_HOME", tmp.join("elsewhere").to_str().unwrap()); + assert_eq!(home(), Some(tmp.join("elsewhere"))); + + // 0 · hermetic: a capture run resolves nothing, even with an override set. + let _c2 = EnvGuard::set("WOW_CAPTURE", "ui-options"); + assert_eq!(home(), None); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn no_install_means_no_phantom_folder() { + let _l = ENV_LOCK.lock().unwrap(); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _h = EnvGuard::unset("BENILLA_HOME"); + let _d = EnvGuard::set("WOW_DATA", "/nonexistent/benilla-test/Data"); + assert_eq!(home(), None); + } + + #[test] + fn write_atomic_creates_dirs_and_replaces_whole_files() { + let _l = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!("benilla-wa-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + let path = tmp.join("nested/config.toml"); + write_atomic(&path, "a = \"1\"\n").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = \"1\"\n"); + write_atomic(&path, "a = \"2\"\n").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = \"2\"\n"); + assert!(!path.with_extension("tmp").exists(), "tmp renamed away"); + std::fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index 9c172800..086b53ad 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -42,6 +42,7 @@ mod combat_text; mod cooldowns; mod creature_anim; mod cursor; +mod cvars; mod dbg_trace; mod death; mod debug_panel; @@ -64,6 +65,7 @@ mod items; mod lighting; mod liquid; mod loading_screen; +mod local_state; mod login; mod map_proj; mod mesh_tag; @@ -302,16 +304,29 @@ fn main() -> AppExit { resolution: if capturing && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") { - match std::env::var("WOW_CAPTURE").as_deref() { - Ok("ui-actionbar") => UVec2::new(1300, 260), - Ok("vplates") => UVec2::new(1024, 768), - // The director's small-window shape: short enough that the action bar - // strip overlaps the chat edit box rows — the overlap is the subject. - Ok("ui-chatedit") => UVec2::new(566, 377), - // The fullscreen map's chrome is a centered 1024×768 block; a hair of - // margin shows the blackout doing its job. - Ok("ui-worldmap") => UVec2::new(1100, 800), - _ => UVec2::new(640, 700), + // `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI + // scenarios (a scale-dependent text bug looks fine at the scenario's + // default size and truncates at fullscreen heights). + if let Some(win) = std::env::var("WOW_WIN").ok().and_then(|v| { + let (w, h) = v.split_once('x')?; + Some(UVec2::new(w.parse().ok()?, h.parse().ok()?)) + }) { + win + } else { + match std::env::var("WOW_CAPTURE").as_deref() { + Ok("ui-actionbar") => UVec2::new(1300, 260), + Ok("vplates") => UVec2::new(1024, 768), + // The director's small-window shape: short enough that the action bar + // strip overlaps the chat edit box rows — the overlap is the subject. + Ok("ui-chatedit") => UVec2::new(566, 377), + // The fullscreen map's chrome is a centered 1024×768 block; a hair of + // margin shows the blackout doing its job. + Ok("ui-worldmap") => UVec2::new(1100, 800), + // The 920×724 era options window wants air on every side so the + // straddling right-edge tile and the hung close X stay in frame. + Ok("ui-options") => UVec2::new(1200, 900), + _ => UVec2::new(640, 700), + } } .into() } else { @@ -608,6 +623,9 @@ fn main() -> AppExit { // `SetPortraitTexture`-bound region (the modern high-res 2D model bake). .add_plugins(PortraitPlugin) .add_plugins((TextInputPlugin, UiScriptPlugin)) + // The CVar host (decision 0954): registration, knob sync, config.toml persistence. After + // UiScriptPlugin only for reading order — its systems gate on the VM existing anyway. + .add_plugins(cvars::CvarPlugin) // The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the // plain data the `Unit*` bindings read, and fires the matching WoW events. .add_plugins(UiUnitPlugin) diff --git a/crates/benilla/src/ui_script/escape_tests.rs b/crates/benilla/src/ui_script/escape_tests.rs index 8a20ec80..62e489fe 100644 --- a/crates/benilla/src/ui_script/escape_tests.rs +++ b/crates/benilla/src/ui_script/escape_tests.rs @@ -159,6 +159,47 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// The options rung (decision 0950; the ref's own `elseif OptionsFrame:IsVisible()` at +/// UIParent.lua l.1483-1484, BETWEEN the popup rung and the menu rung): ESC with the options +/// window up closes it — hide, not cancel-click: benilla applies changes live — and eats the +/// press, so the menu does NOT open on the same stroke. Only the NEXT press, with nothing left +/// to eat, opens the menu (one eater per press, the 0449 law). +#[test] +fn escape_closes_the_options_window_before_opening_the_menu() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "OptionsFrame.xml"); + load_xml(&s, "GameMenuFrame.xml"); + + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert!(s.eval::("return OptionsFrame:IsVisible()").unwrap()); + assert!( + !s.eval::("return GameMenuFrame:IsVisible()").unwrap(), + "the menu is down — the options rung is what must eat this press" + ); + + // Press 1: the options rung eats it — the window closes and the menu stays down. + s.run("ToggleGameMenu()").unwrap(); + assert!( + !s.eval::("return OptionsFrame:IsVisible()").unwrap(), + "ESC closed the options window" + ); + assert!( + !s.eval::("return GameMenuFrame:IsVisible()").unwrap(), + "…and did NOT also open the menu — one eater per press" + ); + + // Press 2: nothing left to eat — the menu opens. + s.run("ToggleGameMenu()").unwrap(); + assert!( + s.eval::("return GameMenuFrame:IsVisible()").unwrap(), + "the next press reaches the ladder's open-the-menu tail" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// ESC closes an open stack-split spinner (decision 0216 §6/slice 2, StackSplit.xml): this engine /// has no plain-frame keyboard capture (the real client's own `StackSplitFrame` OnKeyDown ESCAPE /// arm can't be driven), so the hook rides `ToggleGameMenu`'s shared chain instead — checked diff --git a/crates/benilla/src/ui_script/extract/mod.rs b/crates/benilla/src/ui_script/extract/mod.rs index 56604c00..fc8a5e01 100644 --- a/crates/benilla/src/ui_script/extract/mod.rs +++ b/crates/benilla/src/ui_script/extract/mod.rs @@ -66,9 +66,13 @@ pub(super) fn drive_script( // This frame's `` widget slot, parked for `minimap::emit_minimap` (the UiQuadAppend // producer that fills the hole with tile/arrow quads — decision 0203 phase 1). mut minimap_widget: ResMut, - // Whether the digit-advance feed has run (once, on the first frame the atlas exists — the - // atlas itself bakes once and never re-scales, see `load_fonts_and_build_atlas`). + // Whether the digit-advance feed has run (once per raster environment: on the first frame + // the atlas exists, and again after every seam-scale change — `last_seam` below re-arms it). mut digits_fed: Local, + // The seam scale the engine's text-metric caches were answered under. When `s` moves (window + // resize / fullscreen toggle / uiScale change), every cached measure is stale — see the + // invalidation below. + mut last_seam: Local, // The uiScale dial folded into the seam scale (decision 0584). ui_scale: Res, // ── The extract gate's memory (decision 0740): last frame's conversion inputs ───────────── @@ -96,6 +100,20 @@ pub(super) fn drive_script( // way in (input.rs), and measures ÷s on the way back. At a 768-tall window with the dial at // 1 the whole pipeline is bit-identical to the pre-virtual behavior. let s = super::seam_scale(h, ui_scale.0); + // The raster environment moved (resize / fullscreen / uiScale): every text metric the engine + // caches — FontString measures, chat row counts, editbox advance tables, the digit feed — was + // answered under the OLD `s`, and integer-stepped advances measured at one raster size do not + // rescale to another (the font-size snap alone shifts a string's unit width by several + // percent — enough that a boot-size measure fails the fullscreen fit test and the ellipsis + // eats fitting text: the director's "Contr..." rows, reproduced by `WOW_RESIZE`). Declare the + // staleness at the seam and let every round-trip re-answer under the new `s`. + if *last_seam != s { + if *last_seam != 0.0 { + script.invalidate_text_measures(); + *digits_fed = false; + } + *last_seam = s; + } // Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost // on an idle frame, and the ledger can only rank what has a name — tick (Lua OnUpdate), // resolve (layout), measure (the text round-trips), extract (tree walk + rasterize), diff. @@ -214,11 +232,13 @@ pub(super) fn drive_script( let n = crate::ui_text::measure_wrapped_rows( atlas, &r.text, + // wrap_width is the RESOLVED frame width (scale already in it) — ×s only; + // the font height is frame-local — ×s × the frame's scale, the drawn size. r.wrap_width * s, crate::ui_text::FontSpec { path: r.font.as_deref(), // The band's own drawn px (one-to-one regime; inert ≤ 32). - height: crate::ui_text::drawn_px(r.height, None, s), + height: crate::ui_text::drawn_px(r.height, None, s * r.scale), outline: r.outline, // step-law width bias, as in the measures above paint_halo: true, // measure never paints; irrelevant here alpha_gradient: None, @@ -261,7 +281,11 @@ pub(super) fn drive_script( if let Some(req) = script.editbox_advances_request() { let spec = crate::ui_text::FontSpec { path: req.font.as_deref(), - height: crate::ui_text::drawn_px(req.height, None, s), + // Measured at the DRAWN raster size (seam × the box frame's scale) but divided + // back by the seam alone: the advances return in screen UI units, the space the + // engine's ÷s mouse feed and the box's scale-multiplied rect live in (the + // request's `scale` doc). + height: crate::ui_text::drawn_px(req.height, None, s * req.scale), outline: req.outline, paint_halo: true, // measure never paints alpha_gradient: None, // alpha never changes metrics @@ -549,6 +573,7 @@ pub(super) fn drive_script( ebox: text_ui.as_ref(), screen_h: h, scale: s, + font_scale: eq.scale, caret_pinned: capture.is_some(), }, &mut out, @@ -652,16 +677,21 @@ fn measure_fontstrings( let measures: Vec<(u32, f32, f32, u64)> = requests .iter() .map(|r| { + // The full raster scale: seam × the owner frame's effective_scale. Measure at the + // exact drawn size and divide the whole product back out, so the frame-LOCAL answer + // times the layout's scale lands on the drawn glyphs to the pixel (integer-stepped + // advances don't commute with scaling — the request's `scale` doc). + let rs = s * r.scale; let (w, h) = crate::ui_text::measure_text( atlas, &r.text, - r.wrap_width.map(|w| w * s), + r.wrap_width.map(|w| w * rs), crate::ui_text::FontSpec { path: r.font.as_deref(), - // The render pass's exact drawn px (two regimes × the virtual scale) — + // The render pass's exact drawn px (two regimes × the full scale) — // measure == render, and Lua's GetStringWidth/Height echo the DRAWN size - // (0x772890); results divide back to UI units below. - height: crate::ui_text::drawn_px(r.height, r.text_height, s), + // (0x772890); results divide back to frame-local UI units below. + height: crate::ui_text::drawn_px(r.height, r.text_height, rs), // The TRUE outline: THICK biases the client's step law (+1px per glyph — // GlyphStepBase 0x5ca2b0, THICK-only per outline-bake-tint.md) and any outline // adds the +2r line pitch, so measure must see it. @@ -670,7 +700,7 @@ fn measure_fontstrings( alpha_gradient: None, // alpha never changes metrics }, ); - (r.id, w / s, h / s, r.key) + (r.id, w / rs, h / rs, r.key) }) .collect(); script.set_measured_text(&measures); diff --git a/crates/benilla/src/ui_script/extract/text.rs b/crates/benilla/src/ui_script/extract/text.rs index 30509f4c..ed6fc943 100644 --- a/crates/benilla/src/ui_script/extract/text.rs +++ b/crates/benilla/src/ui_script/extract/text.rs @@ -44,6 +44,13 @@ pub(super) struct TextHost<'a> { /// this converts the remaining unit-space inputs — font heights, shadow offsets, the /// engine's editbox caret/selection x-offsets — into the same px space. pub scale: f32, + /// The owning frame's `effective_scale` ([`benilla_ui::script::ExtractedQuad::scale`]): the + /// rect already carries it; the FONT metrics — glyph raster size and shadow offset, both + /// frame-local — multiply by it here (the real client's text rides `SetScale`; 0219 §2's + /// divergence, closed). NOT applied to the editbox caret/selection/advance x-offsets: those + /// arrive in screen UI units (the advance measure already rode the scale — see + /// [`benilla_ui::script::EditBoxAdvanceRequest`]'s `scale` doc). + pub font_scale: f32, /// Captures pin the caret blink ON (deterministic pixels); live, the engine's phase decides. pub caret_pinned: bool, } @@ -62,10 +69,15 @@ pub(super) fn emit( let base_color = style.color.unwrap_or([1.0, 1.0, 1.0, 1.0]); let spec = crate::ui_text::FontSpec { path: style.font.as_deref(), - // The drawn px under the two size regimes × the 768-virtual scale (`drawn_px`, decision - // 0582): one-to-one text unit-caps at 32 then scales; a SetTextHeight override scales - // uncapped. The shadow twin below inherits the spec (`..spec`). - height: crate::ui_text::drawn_px(style.font_height, style.text_height, host.scale), + // The drawn px under the two size regimes × the 768-virtual scale × the owner's frame + // scale (`drawn_px`, decision 0582): one-to-one text unit-caps at 32 (a frame-LOCAL cap, + // like the seam scale it precedes) then scales; a SetTextHeight override scales uncapped. + // The shadow twin below inherits the spec (`..spec`). + height: crate::ui_text::drawn_px( + style.font_height, + style.text_height, + host.scale * host.font_scale, + ), outline: style.outline, paint_halo: true, alpha_gradient: style.alpha_gradient, @@ -87,8 +99,8 @@ pub(super) fn emit( // below has to admit the ink it puts under the fill. let shadow_delta = style.shadow.map(|sh| { ( - shadow_offset_px(sh.offset[0] * host.scale), - shadow_offset_px(-sh.offset[1] * host.scale), + shadow_offset_px(sh.offset[0] * host.scale * host.font_scale), + shadow_offset_px(-sh.offset[1] * host.scale * host.font_scale), ) }); // A message-frame ring line's scissor follows the seat, at the bottom edge only. diff --git a/crates/benilla/src/ui_script/game_menu_tests.rs b/crates/benilla/src/ui_script/game_menu_tests.rs index df5e3acb..8c178b78 100644 --- a/crates/benilla/src/ui_script/game_menu_tests.rs +++ b/crates/benilla/src/ui_script/game_menu_tests.rs @@ -1,11 +1,12 @@ //! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674). //! -//! What these guard, in order: the ref geometry (195×246 and the eight-button ladder, because the -//! whole point of shipping five dead buttons is that the frame keeps its exact silhouette); the -//! disabled five; the three live buttons' wire intents and sounds; the ESC ladder's two new rungs -//! (open when nothing is left to eat, close before everything else); the micro button's `clicked` -//! toggle; the native-center rule that makes the menu take the screen (windows close on the way in, -//! and nothing opens while it is up); and the camp/quit countdown dialogs end to end. +//! What these guard, in order: the ladder geometry (the ERA menu shape — 200×288, eight rungs in +//! three 20-gapped sections, the era layout engine's own numbers precomputed in the XML's header +//! note); the four greyed pending entries; the live buttons' wire intents and sounds; +//! the ESC ladder's two new rungs (open when nothing is left to eat, close before everything +//! else); the micro button's `clicked` toggle; the native-center rule that makes the menu take +//! the screen (windows close on the way in, and nothing opens while it is up); and the camp/quit +//! countdown dialogs end to end. The Options button's own click path is options_tests'. use benilla_ui::script::{ ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript, @@ -44,25 +45,25 @@ fn harness() -> UiScript { harness_with(&[]) } -/// The eight buttons, top to bottom — the ref's own order (GameMenuFrame.xml l.48-186). +/// The eight buttons, top to bottom — the ERA ladder (the director's call on the 0951 review; +/// GameMenuFrame.xml's header SCOPE note quotes the era source). const LADDER: [&str; 8] = [ "GameMenuButtonOptions", - "GameMenuButtonSoundOptions", - "GameMenuButtonUIOptions", - "GameMenuButtonKeybindings", + "GameMenuButtonAddOns", + "GameMenuButtonEditMode", + "GameMenuButtonSupport", "GameMenuButtonMacros", "GameMenuButtonLogout", "GameMenuButtonQuit", "GameMenuButtonContinue", ]; -/// The frame's geometry, quoted from the reference: 195×246 centered, each button 144×21, the first -/// centered on the frame's TOP at −37, each next 1 px under the last, and Return to Game hung 16 -/// under Exit Game — the single gap that makes the last entry read as separate. Shipping the five -/// unbacked buttons disabled (rather than cutting them) is exactly what keeps this true, so it is -/// the first thing to break if someone "tidies" the ladder. +/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding +/// 32/28/28/28, spacing 0, AddSection gap 20): 200×288, each button 144×21 at x=28, tops at +/// 32/73/94/115/136/177/198/239 — three sections split by the 20-unit gaps after Options, after +/// Macros, and before Return to Game. First thing to break if the era shape is ever "tidied". #[test] -fn the_menu_has_the_reference_frame_and_button_ladder() { +fn the_menu_has_the_era_frame_and_button_ladder() { let mut s = harness(); s.run("ShowUIPanel(GameMenuFrame)").unwrap(); s.resolve(); @@ -70,14 +71,12 @@ fn the_menu_has_the_reference_frame_and_button_ladder() { let (w, h) = s .eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()") .unwrap(); - assert_eq!((w, h), (195.0, 246.0), "the ref frame size"); + assert_eq!((w, h), (200.0, 288.0), "the era frame size"); let top = s.eval::("return GameMenuFrame:GetTop()").unwrap(); - // Button i's own top, measured down from the frame's: the first is centered at −37 (so its top - // is 37 − 21/2 = 26.5 below), then a 22 px stride (21 tall + the 1 px gap), with the last - // pushed a further 15 (its −16 anchor instead of −1). - let mut expected = top - 26.5; - for (i, name) in LADDER.iter().enumerate() { + let left = s.eval::("return GameMenuFrame:GetLeft()").unwrap(); + const TOPS: [f64; 8] = [32.0, 73.0, 94.0, 115.0, 136.0, 177.0, 198.0, 239.0]; + for (name, down) in LADDER.iter().zip(TOPS) { let (bw, bh, btop, bleft) = s .eval::<(f64, f64, f64, f64)>(&format!( "return {name}:GetWidth(), {name}:GetHeight(), {name}:GetTop(), {name}:GetLeft()" @@ -89,41 +88,64 @@ fn the_menu_has_the_reference_frame_and_button_ladder() { "{name} is a GameMenuButtonTemplate" ); assert!( - (btop - expected).abs() < 0.001, - "{name} top: expected {expected}, got {btop}" + (btop - (top - down)).abs() < 0.001, + "{name} top: expected {} down {down}, got {}", + top - down, + btop ); - // Every button is centered in the frame (the ladder is one column). assert!( - (bleft - (512.0 - 72.0)).abs() < 0.001, - "{name} is centered in the 1024-wide screen" + (bleft - (left + 28.0)).abs() < 0.001, + "{name} sits at the era 28-unit left padding" ); - expected -= if i == 6 { 21.0 + 16.0 } else { 21.0 + 1.0 }; } assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The five with nothing behind them are DISABLED — the director's call: the ladder keeps the ref's -/// shape, and a button that does nothing looks like it. The three that work are enabled. +/// The four entries with nothing behind them — AddOns, Edit Mode, Support, Macros — are DISABLED +/// (the pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry). +/// Everything else in the ladder is live. #[test] -fn the_five_unbacked_buttons_are_disabled_and_the_three_live_ones_are_not() { +fn the_unbacked_entries_are_disabled_and_the_rest_are_live() { let s = harness(); s.run("ShowUIPanel(GameMenuFrame)").unwrap(); - for name in &LADDER[..5] { + for name in [ + "GameMenuButtonAddOns", + "GameMenuButtonEditMode", + "GameMenuButtonSupport", + "GameMenuButtonMacros", + ] { assert!( !s.eval::(&format!("return {name}:IsEnabled()")) .unwrap(), "{name} has no panel behind it and must read that way" ); } - for name in &LADDER[5..] { + for name in [ + "GameMenuButtonOptions", + "GameMenuButtonLogout", + "GameMenuButtonQuit", + "GameMenuButtonContinue", + ] { assert!( s.eval::(&format!("return {name}:IsEnabled()")) .unwrap(), "{name} is live" ); } - // The labels are the reference GlobalStrings, read back through the globals (not literals). + // The pending labels read the era strings (AddOns through the ADDONS global). + assert_eq!( + s.eval::("return GameMenuButtonAddOns:GetText()") + .unwrap(), + "AddOns" + ); + assert_eq!( + s.eval::("return GameMenuButtonEditMode:GetText()") + .unwrap(), + "Edit Mode" + ); + // Continue's label is the reference GlobalString, read back through the global; Options is + // the one literal (1.12 GlobalStrings has no GAMEOPTIONS_MENU — the XML says so). assert_eq!( s.eval::("return GameMenuButtonContinue:GetText()") .unwrap(), @@ -132,7 +154,7 @@ fn the_five_unbacked_buttons_are_disabled_and_the_three_live_ones_are_not() { assert_eq!( s.eval::("return GameMenuButtonOptions:GetText()") .unwrap(), - "Video Options" + "Options" ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } @@ -688,3 +710,23 @@ fn backpack() -> ContainerState { slots, } } + +/// The menu wears the shared era-window scale on show (`ERA_WINDOW_SCALE`, OptionsFrame.xml — +/// the stand-in for the era client's UIParent px-per-unit): in the era the menu and the options +/// window draw at the SAME density (the menu has no scale of its own; options' checkFit caps at +/// 1), and ours drew the menu 28% larger relative to the options window until it rode the same +/// knob ("esc menu scale too big" — director, 2026-08-04). Loaded WITH OptionsFrame.xml, the +/// real load order; the bare-menu harness elsewhere exercises the `or 1` guard implicitly. +#[test] +fn the_menu_rides_the_shared_era_window_scale() { + let mut s = harness_with(&["OptionsFrame.xml"]); + s.run("ShowUIPanel(GameMenuFrame)").unwrap(); + s.resolve(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + let ran = s.eval::("return BENILLA_MENU_ONSHOW or -99").unwrap(); + println!("handler ran probe: {ran}"); + let k = s.eval::("return GameMenuFrame:GetScale()").unwrap(); + let want = s.eval::("return ERA_WINDOW_SCALE").unwrap(); + assert!((k - want).abs() < 1e-6, "menu scale {k} != knob {want}"); + assert!((want - 0.78).abs() < 1e-6, "the knob itself moved: {want}"); +} diff --git a/crates/benilla/src/ui_script/mod.rs b/crates/benilla/src/ui_script/mod.rs index e489d76a..a4775c01 100644 --- a/crates/benilla/src/ui_script/mod.rs +++ b/crates/benilla/src/ui_script/mod.rs @@ -135,7 +135,7 @@ impl Default for UiScaleCvar { /// The shipped default (the reference's slider spans 0.64..1.0; 1.0 read oversized to the /// director's eye — the taste call this dial exists for). -const DEFAULT_UI_SCALE: f32 = 0.9; +pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9; /// The app's `uiScale`: `WOW_UI_SCALE=` if set (clamped to the plausible dial range), else /// [`DEFAULT_UI_SCALE`] — the env override makes taste iteration a relaunch, not a rebuild. @@ -202,7 +202,9 @@ impl Plugin for UiScriptPlugin { arbitrate_pointer_over_ui .after(UiInput) .before(WorldStage::Input), - ); + ) + // Runtime era-atlas misses surface as WARNs the frame they happen (decision 0950). + .add_systems(Update, warn_era_atlas_misses.after(UiInput)); // `WOW_CAPTURE_UI=1` on a capture: override the "player" token with a synthetic snapshot so // the unit frames are populated in a server-less capture. Ordered after the real feed (so it @@ -238,7 +240,7 @@ fn arbitrate_pointer_over_ui( } fn setup_script(world: &mut World) { - let script = match UiScript::new() { + let mut script = match UiScript::new() { Ok(s) => s, Err(e) => { error!("ui_script: VM init failed: {e}"); @@ -247,6 +249,12 @@ fn setup_script(world: &mut World) { }; load_global_strings(world, &script); load_emote_tokens(world, &script); + // The Era atlas table (decision 0950) — pushed BEFORE the XML loads so `atlas=` attributes + // resolve during materialization. A missing manifest already WARNed inside the loader; the + // windows then draw blank and every unresolved name is drained as a miss below. + if let Some(entries) = crate::assets::era::load_era_atlases() { + script.set_era_atlases(entries); + } // The unit frames are the default player UI — always loaded in a normal run. Captures stay // pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in. let capturing = world.contains_resource::(); @@ -255,9 +263,23 @@ fn setup_script(world: &mut World) { // assertion (`shipped_xml_tests`), not a second reporting channel. let _ = load_default_ui(&script); } + // Boot-time atlas misses (an `atlas=` or OnLoad `SetAtlas` the table couldn't serve) name + // themselves once, here; runtime misses drain per frame in `warn_era_atlas_misses`. + for name in script.take_era_atlas_misses() { + warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py"); + } world.insert_non_send_resource(script); } +/// Per-frame drain of runtime `SetAtlas` misses (hover/selection swaps naming an atlas the boot +/// table lacks) — warn-once per name, the boot drain's live twin. Free when nothing missed. +fn warn_era_atlas_misses(script: Option>) { + let Some(mut script) = script else { return }; + for name in script.take_era_atlas_misses() { + warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py"); + } +} + /// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM — /// the reference boots FrameXML with exactly this file FIRST, and it is the source of every /// localized string global the UI reads (the cast-fail display's whole message set, 0427). @@ -599,6 +621,11 @@ fn load_default_ui(script: &UiScript) -> Vec { // alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog(). "QuestLogFrame.xml", "ChatFrame.xml", + // The options window (decision 0950): the era-skinned OptionsFrame the game menu's + // Options button opens. Needs only Fonts' objects and UiPanels' panel manager (both far + // above); sits right before GameMenuFrame so the menu — which reaches it by name at + // click time — stays the last, outermost layer. + "OptionsFrame.xml", // The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their // event driver. LAST on purpose — it is the outermost layer of the shell, it depends on // everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's @@ -903,6 +930,9 @@ mod escape_tests; #[cfg(test)] mod game_menu_tests; +#[cfg(test)] +mod options_tests; + #[cfg(test)] mod delete_item_tests; diff --git a/crates/benilla/src/ui_script/options_tests.rs b/crates/benilla/src/ui_script/options_tests.rs new file mode 100644 index 00000000..6a242611 --- /dev/null +++ b/crates/benilla/src/ui_script/options_tests.rs @@ -0,0 +1,228 @@ +//! The shipped `assets/ui/OptionsFrame.xml` — the era-skinned options window (decision 0950), +//! slice A: the shell. +//! +//! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels + +//! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's +//! own kit); Controls is the default category and the page title follows the selection; both +//! close spellings put the window away; and — against a hand-pushed atlas table, the +//! era_atlas_tests idiom — the selected row actually wears `options_list_active` and drops it +//! when the selection moves. The harness pushes NO atlas table by default: `SetAtlas` is a +//! warn-once no-op there by design (a missing extraction must not kill the UI), which is exactly +//! why the one atlas-backed test pushes its own two members. + +use benilla_ui::script::{EraAtlasEntry, SoundRequest, UiScript}; + +/// The window's real neighbourhood, in the manifest's own order (options before the menu — the +/// game_menu_tests::harness_with idiom, minus the extras this file never needs). +fn harness() -> UiScript { + harness_on(UiScript::new().unwrap()) +} + +/// Load the four files onto a prepared script — split out so the atlas test can push its table +/// BEFORE the XML loads, the way the app does (ui_script::setup_script). +fn harness_on(mut s: UiScript) -> UiScript { + s.set_screen_size(1024.0, 768.0); + for file in [ + "Fonts.xml", + "UiPanels.xml", + "OptionsFrame.xml", + "GameMenuFrame.xml", + ] { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets/ui") + .join(file), + ) + .unwrap(); + let doc = benilla_ui::framexml::parse(&text).unwrap(); + let report = benilla_ui::loader::load(&s, &doc, &|_| None); + assert!( + report.errors.is_empty(), + "{file}: loader errors: {:?}", + report.errors + ); + // The dialect announces DROPPED subtrees as warnings, not errors — for the new file, + // a warning is a silently-missing piece of chrome, so it fails here. + if file == "OptionsFrame.xml" { + assert!( + report.warnings.is_empty(), + "{file}: loader warnings (dropped subtrees?): {:?}", + report.warnings + ); + } + } + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + s +} + +/// The door in: the game menu's Options button (its ref OnClick + the explicit hide) swaps the +/// two native-center frames — menu down, options up holding the center slot — on the ref's own +/// igMainMenuOption kit. +#[test] +fn the_menu_options_button_swaps_the_menu_for_the_options_window() { + let mut s = harness(); + s.run("ShowUIPanel(GameMenuFrame)").unwrap(); + let _ = s.take_sounds(); + + s.run("GameMenuButtonOptions:Click()").unwrap(); + assert!( + s.eval::("return OptionsFrame:IsVisible()").unwrap(), + "the options window opened" + ); + assert!( + !s.eval::("return GameMenuFrame:IsVisible()").unwrap(), + "…and the menu went away first" + ); + assert!( + s.eval::("return GetCenterFrame():GetName() == \"OptionsFrame\"") + .unwrap(), + "the window holds the native-center slot the menu vacated" + ); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuOption".into()))); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Controls is the default page (the OnShow seat), and the page title is the selected row's own +/// label — including the one key whose label differs from it (ActionBars → "Action Bars"). +#[test] +fn controls_is_the_default_category_and_the_title_reads_it() { + let s = harness(); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + + assert_eq!( + s.eval::("return OptionsFrame.selectedCategory") + .unwrap(), + "Controls" + ); + assert_eq!( + s.eval::("return OptionsFrameContainerTitle:GetText()") + .unwrap(), + "Controls" + ); + // The row labels are the era category tree's. + assert_eq!( + s.eval::("return OptionsFrameCategoryListRowActionBars:GetText()") + .unwrap(), + "Action Bars" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Clicking a category row moves the selection and the page title with it — and the selection +/// survives a close/reopen (the OnShow re-applies the last seat, not the default). +#[test] +fn clicking_a_row_moves_the_selection_and_the_page_title() { + let s = harness(); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + + s.run("OptionsFrameCategoryListRowGraphics:Click()") + .unwrap(); + assert_eq!( + s.eval::("return OptionsFrame.selectedCategory") + .unwrap(), + "Graphics" + ); + assert_eq!( + s.eval::("return OptionsFrameContainerTitle:GetText()") + .unwrap(), + "Graphics" + ); + + // Close and reopen: still Graphics, not Controls. + s.run("HideUIPanel(OptionsFrame)").unwrap(); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert_eq!( + s.eval::("return OptionsFrameContainerTitle:GetText()") + .unwrap(), + "Graphics" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Both close spellings — the red Close and the corner X — hide the window on the shared +/// igMainMenuClose kit, and the Defaults button (nothing to default this slice) reads disabled. +#[test] +fn both_close_buttons_hide_the_window_and_defaults_is_disabled() { + let mut s = harness(); + + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert!( + !s.eval::("return OptionsFrameContainerDefaults:IsEnabled()") + .unwrap(), + "Defaults is dead until the pages slice brings rows to default" + ); + let _ = s.take_sounds(); + s.run("OptionsFrameCloseButton:Click()").unwrap(); + assert!(!s.eval::("return OptionsFrame:IsVisible()").unwrap()); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuClose".into()))); + + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + let _ = s.take_sounds(); + s.run("OptionsFrameClosePanelButton:Click()").unwrap(); + assert!(!s.eval::("return OptionsFrame:IsVisible()").unwrap()); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuClose".into()))); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The row art, against a hand-pushed two-member atlas table (real manifest numbers, the +/// era_atlas_tests idiom): the selected row's bg wears `options_list_active` at atlas size, and +/// a moved selection strips the old row bare (`SetTexture(nil)` → GetAtlas nil). The window's +/// OTHER atlas members (nine-slice, divider, inner panel) stay unserved here on purpose — they +/// drain as warn-once misses, never errors. +#[test] +fn the_selected_row_wears_the_active_atlas_and_yields_it_on_reselect() { + let mut s = UiScript::new().unwrap(); + s.set_era_atlases([ + ( + "options_list_active".to_string(), + EraAtlasEntry { + file: "era:textures/1318750.blp".to_string(), + uv: [604.0 / 1024.0, 791.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0], + size: [187.0, 21.0], + }, + ), + ( + "options_list_hover".to_string(), + EraAtlasEntry { + file: "era:textures/1318750.blp".to_string(), + uv: [793.0 / 1024.0, 980.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0], + size: [187.0, 21.0], + }, + ), + ]); + let s = harness_on(s); + + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert_eq!( + s.eval::("return OptionsFrameCategoryListRowControlsBg:GetAtlas()") + .unwrap(), + "options_list_active", + "the default selection wears the active art" + ); + // useAtlasSize rode along: the bg is the member's nominal 187×21, wider than the 175 row — + // the era's own look. + assert_eq!( + s.eval::("return OptionsFrameCategoryListRowControlsBg:GetWidth()") + .unwrap(), + 187.0 + ); + + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + assert!( + s.eval::("return OptionsFrameCategoryListRowControlsBg:GetAtlas() == nil") + .unwrap(), + "the old row stripped bare when the selection moved" + ); + assert_eq!( + s.eval::("return OptionsFrameCategoryListRowAudioBg:GetAtlas()") + .unwrap(), + "options_list_active" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +}