A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy
+
+
+
+
+
+
-[](https://discord.gg/wJSJx467G4)
-[](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8)
-[](#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 @@
-
+
+
+
-
-