the Options window goes era, and settings finally persist

- one era-style Options window: window chrome, category list, search box and its own ESC rung, with the game menu reshaped to match
- settings are real CVars now: GetCVar/SetCVar reach Lua, and your changes persist as a diff in benilla/config.toml
- all local state lives in one visible benilla/ folder beside your Data/ — nothing hides in platform config dirs
- UI text stays crisp at any scale and survives a mid-session resolution change: glyphs measure in the environment they raster in

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-04 18:54:37 +07:00
co-authored by Claude Fable 5
parent 422e1b08e7
commit 7eca017eb0
32 changed files with 2876 additions and 210 deletions
Generated
+53
View File
@@ -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"
+57 -74
View File
@@ -1,94 +1,77 @@
# benilla
<div align="center">
<img src="banner.jpg" alt="">
<h1>benilla</h1>
<p><b>A from-scratch World of Warcraft 1.12.1 client in Rust and <a href="https://bevy.org">Bevy</a></b></p>
<p>
<a href="https://discord.gg/wJSJx467G4"><img src="https://img.shields.io/discord/1529280129518538922?style=for-the-badge&logo=discord&logoColor=white&label=discord&color=5865F2" alt="Discord"></a>
<a href="https://www.youtube.com/playlist?list=PLdCnpZNKxyb8"><img src="https://img.shields.io/badge/devlog-youtube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube devlog"></a>
<a href="LICENSE-MIT"><img src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-blue?style=for-the-badge" alt="License"></a>
</p>
</div>
[![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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+19 -1
View File
@@ -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 <Color> is a tint (vertex color).
+247
View File
@@ -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<Item = (&'a str, &'a str)>) {
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<String> {
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<String> {
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::<Model>().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::<Model>().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::<Model>().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::<String>(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::<String>(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::<String>(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::<bool>(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<String> = 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"));
}
}
@@ -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,
@@ -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#"<Ui>
<Frame name="T">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentInner" atlas="Options_InnerFrame" useAtlasSize="true"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.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::<String>("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::<f32>("return TInner:GetWidth()").unwrap(), 886.0);
assert_eq!(s.eval::<f32>("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#"<Ui>
<Frame name="T">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="$parentTex" atlas="options_innerframe"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.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::<String>("return TTex:GetAtlas()").unwrap(),
"checkbox-minimal"
);
assert_eq!(s.eval::<f32>("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::<bool>("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#"<Ui>
<Frame name="T">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="$parentTex" atlas="not-extracted-yet"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.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::<bool>("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());
}
+17 -5
View File
@@ -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,
);
+38 -7
View File
@@ -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<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
@@ -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<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
@@ -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,
});
}
}
+65 -3
View File
@@ -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<Item = (String, EraAtlasEntry)>) {
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<String> {
let mut misses: Vec<String> = 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
+23 -2
View File
@@ -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<RegionHandle, RegionData>,
/// 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<String, EraAtlasEntry>,
/// 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<String>,
/// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `<Backdrop>` 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<SoundRequest>,
/// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered
/// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
/// `(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<String>,
/// 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<u32, ActionSlot>,
@@ -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,
+61 -1
View File
@@ -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::<Model>().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<bool>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().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::<Model>().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).
@@ -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::<f64>("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::<f64>("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());
}
+45 -2
View File
@@ -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<Rect>,
/// 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<f32>,
/// A texture path (`SetTexture("Interface\\...")`).
pub(crate) texture: Option<String>,
/// 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<String>,
/// 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<String>,
pub height: Option<f32>,
/// 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,
+6
View File
@@ -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
+106 -44
View File
@@ -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.
-->
<Script><![CDATA[
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
VIDEOOPTIONS_MENU = VIDEOOPTIONS_MENU or "Video Options"
SOUNDOPTIONS_MENU = SOUNDOPTIONS_MENU or "Sound Options"
UIOPTIONS_MENU = UIOPTIONS_MENU or "Interface Options"
KEY_BINDINGS = KEY_BINDINGS or "Key Bindings"
ADDONS = ADDONS or "AddOns"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
MACROS = MACROS or "Macros"
LOGOUT = LOGOUT or "Logout"
EXIT_GAME = EXIT_GAME or "Exit Game"
@@ -68,9 +87,9 @@
QUIT_NOW = QUIT_NOW or "Exit now"
CANCEL = CANCEL or "Cancel"
-- One label setter for the five that have nothing behind them yet: set the ref's text, then
-- disable (grey label + the template's -Disabled art). The whole divergence lives here, so
-- making one live is deleting its name from the call and writing its ref OnClick.
-- One label setter for a button with nothing behind it yet (only Macros, since 0950 made
-- Options live): set the ref's text, then disable (grey label + the template's -Disabled
-- art). Making it live is deleting the call and writing its ref OnClick.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
@@ -138,10 +157,20 @@
end
]]></Script>
<!-- ref GameMenuButtonTemplate (UIPanelTemplates.xml l.403-410) = UIPanelButtonTemplate at
144x21 with a white/white/grey label trio. UIPanelButtonTemplate itself is not transcribed
as a shared virtual (no other benilla file inherits it — each open-codes the same four-state
art, e.g. QuestFrame.xml l.753); its textures are inlined here with the ref TexCoords. -->
<!-- The era header text size (Classic/Dialog/DialogTemplates.xml + the OnLoad font swap):
GameFontNormalMed1 = SystemFont_Med2 = FRIZQT 13, gold, the MasterFont shadow. Era's
name, declared here beside its one consumer (the 0951 residue convention: Fonts.xml
stays the complete 1.12 set). -->
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<!-- Era MainMenuFrameButtonTemplate (Classic/Frame/MainMenuFrameTemplates.xml l.11-16) =
UIPanelButtonTemplate at 144x21 with a white/white/grey label trio — byte-for-byte the
1.12 GameMenuButtonTemplate (UIPanelTemplates.xml l.403-410): same art, same trio, same
size. UIPanelButtonTemplate itself is not transcribed as a shared virtual (no other
benilla file inherits it — each open-codes the same four-state art, e.g. QuestFrame.xml
l.753); its textures are inlined here with the ref TexCoords. -->
<Button name="GameMenuButtonTemplate" virtual="true">
<Size><AbsDimension x="144" y="21"/></Size>
<ButtonText name="$parentText"/>
@@ -155,7 +184,9 @@
</Button>
<Frame name="GameMenuFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<Size><AbsDimension x="195" y="246"/></Size>
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around eight 21-unit rungs with three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="288"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
@@ -168,7 +199,10 @@
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="GameMenuFrameTitle" inherits="GameFontNormal">
<!-- Era swaps the header text to 13px (MainMenuFrameMixin:OnLoad →
SetHeaderFont(GameFontNormalMed1)); the seat is the 1.12 one (within half a
unit of era's — header SCOPE note). -->
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
@@ -176,40 +210,58 @@
</Layer>
</Layers>
<Frames>
<!-- The five with no panel behind them (header SCOPE note). Each keeps the ref's own
OnClick quoted above it, so going live is deleting the _Pending call and pasting it:
PlaySound("igMainMenuOption"); ShowUIPanel(OptionsFrame) -->
<!-- The era ladder (header SCOPE note): Options · gap · AddOns, Edit Mode, Support,
Macros · gap · Logout, Exit Game · gap · Return to Game. A TOPLEFT chain at the
era layout engine's numbers — x 28, first top -32, 0 between group members, -20
at each section break. Live entries wire the era OnClick intent; pending ones
grey out through GameMenuButton_Pending until something exists behind them. -->
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
there, OptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
the center-replacement rule for the swap (ShowUIPanel l.697-702: native-center
frames replace each other); hiding the menu explicitly says the same thing
without relying on the eviction path. -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="CENTER" relativePoint="TOP"><Offset><AbsDimension x="0" y="-37"/></Offset></Anchor>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, VIDEOOPTIONS_MENU)</OnLoad></Scripts>
<Scripts>
<OnLoad>self:SetText("Options")</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(OptionsFrame)
</OnClick>
</Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); ShowUIPanel(SoundOptionsFrame) -->
<Button name="GameMenuButtonSoundOptions" inherits="GameMenuButtonTemplate">
<!-- The middle section, all pending (header SCOPE note).
AddOns: the era addon-list dialog — nothing to list yet.
Edit Mode: the era HUD editor — no edit mode exists.
Support: opens the external help site in era — no browser seam.
Macros: ref OnClick when live: PlaySound("igMainMenuOption");
HideUIPanel(GameMenuFrame); ShowMacroFrame() -->
<Button name="GameMenuButtonAddOns" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, SOUNDOPTIONS_MENU)</OnLoad></Scripts>
<Scripts><OnLoad>GameMenuButton_Pending(self, ADDONS)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); ShowUIPanel(UIOptionsFrame) -->
<Button name="GameMenuButtonUIOptions" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonEditMode" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonSoundOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonAddOns" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, UIOPTIONS_MENU)</OnLoad></Scripts>
<Scripts><OnLoad>GameMenuButton_Pending(self, HUD_EDIT_MODE_MENU)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); KeyBindingFrame_LoadUI(); ShowUIPanel(KeyBindingFrame) -->
<Button name="GameMenuButtonKeybindings" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonSupport" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonUIOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonEditMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, KEY_BINDINGS)</OnLoad></Scripts>
<Scripts><OnLoad>GameMenuButton_Pending(self, GAMEMENU_SUPPORT)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); HideUIPanel(GameMenuFrame); ShowMacroFrame() -->
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonKeybindings" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, MACROS)</OnLoad></Scripts>
</Button>
@@ -220,7 +272,7 @@
re-read that state. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(LOGOUT)</OnLoad>
@@ -240,7 +292,7 @@
</Button>
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(EXIT_GAME)</OnLoad>
@@ -260,7 +312,7 @@
</Button>
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-16"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(RETURN_TO_GAME)</OnLoad>
@@ -280,6 +332,16 @@
<Scripts>
<OnLoad>GameMenuFrameTitle:SetText(MAIN_MENU)</OnLoad>
<OnShow>
-- The shared era-window scale first (ERA_WINDOW_SCALE, OptionsFrame.xml — loaded
-- before this file): the era menu has no scale of its own (GameMenuFrame.xml +
-- MainMenuFrameTemplates, grepped clean), it draws at UIParent's px-per-unit —
-- the SAME density as the era options window, whose checkFit caps at 1. The knob
-- is our stand-in for that UIParent scale; without it the menu drew 28% larger
-- relative to the options window than the era pair ("esc menu scale too big" —
-- director, 2026-08-04). `or 1` guards a load-order regression only. NOTE: this
-- lives INSIDE the existing OnShow — a second Scripts/OnShow block on the same
-- frame would silently REPLACE this one (SetScript semantics: last wins).
this:SetScale(ERA_WINDOW_SCALE or 1)
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
</OnShow>
+665
View File
@@ -0,0 +1,665 @@
<Ui>
<!--
The OPTIONS window (decision 0950) — slice A: the shell. Opened from the game menu's
Options button; the pages themselves (setting rows backed by real benilla knobs) come in
the next slice, so the page body ships EMPTY and Defaults ships disabled.
This window is the one deliberate era-1.15.9 skin in a 1.12 shell (0950): the 1.12
OptionsFrame family (four separate panels) collapses into the era SettingsPanel look —
one window, a category list, one page area. Ground truth is the era dump
(wow-ui-source-era @ 1.15.9.68940), transcribed via the session's reference spec:
· **Root** — Blizzard_SettingsPanel.xml `SettingsPanel`: 920x724, CENTER, strata HIGH,
hidden. Skin per `SettingsFrameTemplate` (Classic/Blizzard_SettingsPanelTemplates.xml):
FlatPanelBackgroundTemplate at TOPLEFT (7,-18) → BOTTOMRIGHT (-3,3), the
`ButtonFrameTemplateNoPortrait` nine-slice (Classic/NineSliceLayouts.lua l.132 — right
edge x=-8/x1=8), the `_UI-Frame-TitleTileBg` title band (256x17, TexCoords
.2890625/.421875, TOPLEFT (8,-3) → TOPRIGHT (0,-3)), title GameFontNormal TOP (0,-5).
· **Category list** — Blizzard_CategoryList.xml + .lua: rows/headers STRETCH to the
scroll target's width (199, no scroll bar); header 30 tall (label 14px white at
LEFT (20,-1), Options_CategoryHeader_<n> wash at TOPLEFT at atlas size, n cycling
per header); row 20 tall (label GameFontNormal TOPLEFT (36,+1); bg CENTER —
options_list_active selected / options_list_hover hover, at atlas size); 2 between
elements, 18-tall spacer between groups (so 22 row-to-header).
· **Page area** — Blizzard_SettingsList.xml: title 20px white TOPLEFT (7,-22), the
options_horizontaldivider at TOP (0,-50), Defaults 96x22 at TOPRIGHT (-36,-16).
· **Tabs** — SharedXML Shared/Tabs/MinimalTab.{xml,lua}: a bottom-anchored three-slice
of Options_Tab[_Active]_{Left,Middle,Right}, GameTab TOPLEFT (32,-27) h37, AddOnsTab
5 right of it; width text+40; selected = white small text at BOTTOM+6, unselected =
gold small at BOTTOM+4.
· **Search box** — SharedXML SearchBoxTemplate → InputBoxVisualTemplate
(SecureUIPanelTemplates.xml l.42-67): the common-search-border three-slice (8x20 caps,
left hung 5 outside, middle stretched, all centered on the 22 box), 10x10 magnifier at
LEFT (1,-1), 17x17 clear button at RIGHT (-3), TextInsets 16/20, instructions label
GameFontDisableSmall tinted 0.35 at TOPLEFT (16,0) → BOTTOMRIGHT (-20,0).
Atlas members resolve by name through WoW-era/_extracted_ui/manifest.json (the 0950
seam); era-only FILE textures ride the same extraction as `era:textures/<fdid>.blp`.
SCOPE / divergences (noted, not bugs):
- **horizTile/vertTile are unsupported**, so every repeating tile is STRETCHED for now:
the `_UI-Frame-TitleTile` top band (a 256x28 repeat), the title-bg band under it, the
`!UI-Frame-LeftTile`/`!UI-Frame-RightTile` columns and the `_UI-Frame-Bot` strip. At
this window's spans the stretch reads as a soft smear of the tile — the gap to close
when tiling lands.
- **FlatPanelBackgroundTemplate is ONE solid fill here.** The era template is four
textures (two rounded-corner atlas pieces + two fills) all tinted the engine-side
global PANEL_BACKGROUND_COLOR = (0.122, 0.118, 0.129, 0.8) — the constant is defined in
the client binary, not the Lua dump; value read off the GlobalColor db2 mirror
(warcraft.wiki.gg/wiki/ColorMixin). One flat quad loses only the 16px rounded bottom
corners. The "lighter inner panel" on top of it is not a second fill — it is the real
`Options_InnerFrame` atlas member, placed below.
- **Close X wears the 1.12 UI-Panel-MinimizeButton art at the era seat** — which IS the
era's own close button: 1.15's UIPanelCloseButtonNoScripts names the same four
Interface\Buttons\UI-Panel-MinimizeButton-* files (Classic SharedUIPanelTemplates.xml
l.172-180), TOPRIGHT (+4,+4), 32x32. No divergence here at all.
- **Tabs are STATIC MinimalTab plates** (slice B): Game wears the selected state
permanently, AddOns the unselected gold — the era swaps these in code
(MinimalTabMixin), and with nothing behind AddOns the selection can never change, so
the two states are authored rather than driven. The mixin port comes with the addon
list. Width is era's text+40 law via an OnUpdate measure poll; the era's
measure-at-the-medium-font quirk is deliberately dropped (~6 units of padding).
- **Search filters nothing yet** — the box takes text, shows/hides its "Search"
placeholder and clear-X; wiring it to the category/row filter comes with the pages
slice.
- **No scroll bars**: the static category tree (9 rows, 2 headers) fits its 199px column
outright, and the page body is empty. MinimalScrollBar arrives with the pages.
- Era rows this slice does not ship: the Accessibility group, and Languages/Network under
System — pages with no benilla knobs behind them yet (0950's "only rows backed by real
knobs" rule). Nameplates joined in slice B (real nameplate knobs exist to back its
page).
- **The window rides a scale knob** (slice B): fonts ride frame scale engine-wide now
(the 0219 §2 divergence closed), so ERA_WINDOW_SCALE × the era checkFit shrink
(UIPanelUpdateScaleForFit, transcribed in this file) sizes the whole window; 0.78 is
the director's-eye starting point, live-tunable. The knob is SHARED: it stands in for
the era client's UIParent scale (smaller than our 1.12 768-canvas on modern screens),
so every era-shaped window wears it — GameMenuFrame.xml reads it in its OnShow.
-->
<!-- The two era fonts this window needs beyond Fonts.xml's 1.12 set (the era
GameFontHighlightMedium / GameFontHighlightHuge — Blizzard_Fonts_Shared: FRIZQT 14 and
20, white). Named Options* rather than the era names on purpose: Fonts.xml is the
COMPLETE 1.12 set and stays that way; these are this window's own, defined with
MasterFont's exact shadow inlined (its only content — Fonts.xml l.62). -->
<Font name="OptionsFontHighlightMedium" font="Fonts\FRIZQT__.TTF" virtual="true">
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
<FontHeight><AbsValue val="14"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="OptionsFontHighlightHuge" font="Fonts\FRIZQT__.TTF" virtual="true">
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
<FontHeight><AbsValue val="20"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Script><![CDATA[
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
-- name suffixes; the visible label doubles as the page title (the era does the same —
-- Settings.RegisterCategory names both from one string).
OPTIONS_CATEGORY_KEYS = {
"Controls", "Interface", "ActionBars", "Combat", "Social", "Keybindings", "Nameplates",
"Graphics", "Audio",
}
-- The era-window scale (shared, director-owned — the 0219 WORLDMAP_WINDOWED_SCALE
-- pattern): the era client's UIParent draws smaller against the screen than our 1.12
-- 768-canvas does on modern displays, and this knob is that mapping — every era-shaped
-- window wears it (this window here; GameMenuFrame in its OnShow — in the era both draw
-- at the SAME UIParent px-per-unit, the parity the director's eye checked). At 1.0 the
-- 724-tall window fills ~85% of the screen's height in our 768-virtual space, where the
-- reference at typical resolutions reads nearer two-thirds.
ERA_WINDOW_SCALE = 0.78
function OptionsFrame_UpdateScale()
-- The era panel is registered `checkFit = 1`: the panel manager also shrinks it to
-- fit a small screen (UIPanelUpdateScaleForFit: min over width/height ratios
-- against the panel + 20 units of air each way, capped at 1) — transcribed here.
local k = ERA_WINDOW_SCALE
local wFit = GetScreenWidth() / 940
local hFit = GetScreenHeight() / 744
if wFit < k then k = wFit end
if hFit < k then k = hFit end
OptionsFrame:SetScale(k)
end
-- The era tab width law (MinimalTab.lua l.7): label width + 40. Era measures at OnLoad;
-- our measure round-trip answers a frame late, so OnUpdate polls until the width is real
-- (the chat-header converge idiom), then latches. Deliberate divergence: the era
-- measures with the MEDIUM font it is about to swap away (an accident of its code
-- order, ~6 units of extra padding); we measure the small font the label actually
-- wears.
function OptionsTab_FitWidth(tab)
if tab.fitted then return end
local w = getglobal(tab:GetName() .. "Text"):GetStringWidth()
if w and w > 0 then
tab:SetWidth(w + 40)
tab.fitted = true
end
end
-- Each row's OnLoad: remember the key + title, paint the label. The whole row identity
-- lives here so adding a category is one XML row + one key in the table above.
function OptionsCategoryRow_OnLoad(row, key, label)
row.categoryKey = key
row.pageTitle = label
row:SetText(label)
end
-- The selection: exactly one row wears options_list_active + a white label; everyone
-- else drops back to bare + gold (the era CategoryList mixin's SetCategoryActive, cut to
-- our static list). Also writes the page title — the page BODY stays empty this slice.
function OptionsFrame_SelectCategory(key)
OptionsFrame.selectedCategory = key
for _, k in ipairs(OPTIONS_CATEGORY_KEYS) do
local row = getglobal("OptionsFrameCategoryListRow" .. k)
local bg = getglobal("OptionsFrameCategoryListRow" .. k .. "Bg")
if k == key then
bg:SetAtlas("Options_List_Active", true)
row:SetTextFontObject("GameFontHighlight")
OptionsFrameContainerTitle:SetText(row.pageTitle)
else
bg:SetTexture(nil)
row:SetTextFontObject("GameFontNormal")
end
end
end
]]></Script>
<!-- The red panel button (era UIPanelButtonTemplate seat, 96x22): GameMenuButtonTemplate's
four-state UI-Panel-Button art + white/white/grey label trio, copied at this window's
size (GameMenuFrame.xml l.145 is the shared original; templates don't cross files). -->
<Button name="OptionsRedButtonTemplate" virtual="true">
<Size><AbsDimension x="96" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlight"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
</Button>
<!-- One category row (era Blizzard_CategoryList.xml's category button): the template's
175 width is never rendered in the era either — the ScrollBox stretches every row to
the scroll target's width, 199 with no scroll bar (CategoryList.lua l.176-197 +
ScrollBoxViewUtil), and ours is the no-bar case. Gold label centered in the row (the
full-row box below); a
CENTER bg that is EMPTY until the hover/selected atlas lands on it (options_list_hover
/ options_list_active at atlas size — 187x21, centered: 6 units in from either edge of
the 199 row, the alignment the first cut's 175-wide rows broke). Selection is
OptionsFrame_SelectCategory's; hover only ever paints a row that is NOT the selection. -->
<Button name="OptionsCategoryRowTemplate" virtual="true">
<Size><AbsDimension x="199" y="20"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBg">
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
</Layers>
<!-- The era label is a box spanning the WHOLE row — TOPLEFT (36,+1) AND BOTTOMRIGHT
(0,+1), era CategoryList.xml l.72-77 — so the default MIDDLE vertical justify
CENTERS the text in the 20-tall row (1 unit high of true center, the era's own
nudge). The first cut had only the TOPLEFT corner: the auto-sized label hung from
the row TOP, ~4 units high of center ("text not vertically centered" — director,
2026-08-04). justifyH LEFT is the era's own attribute; a full-row box without it
would center horizontally. -->
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="36" y="1"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontNormal"/>
<DisabledFont inherits="GameFontDisable"/>
<Scripts>
<OnClick>OptionsFrame_SelectCategory(this.categoryKey)</OnClick>
<OnEnter>
if OptionsFrame.selectedCategory ~= this.categoryKey then
getglobal(this:GetName() .. "Bg"):SetAtlas("Options_List_Hover", true)
end
</OnEnter>
<OnLeave>
if OptionsFrame.selectedCategory ~= this.categoryKey then
getglobal(this:GetName() .. "Bg"):SetTexture(nil)
end
</OnLeave>
</Scripts>
</Button>
<!-- A category group header (era SettingsCategoryListHeaderTemplate, stretched to the 199
row width like the rows): a 14px white label at LEFT (20,-1) over the group's
BACKGROUND WASH — the Options_CategoryHeader_<n> atlas the era Init pins at the
header's TOPLEFT at atlas size (199x144: a header band whose gradient runs down over
the group's own rows; the rows' hover/selected plates draw above it, being later
siblings). The <n> cycles per header (CategoryList.lua l.378-384) — each instance's
OnLoad passes its own. -->
<Frame name="OptionsCategoryHeaderTemplate" virtual="true">
<Size><AbsDimension x="199" y="30"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentBg">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentLabel" inherits="OptionsFontHighlightMedium">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="20" y="-1"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="OptionsFrame" frameStrata="HIGH" enableMouse="true" hidden="true">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<!-- FlatPanelBackgroundTemplate as one fill (header SCOPE note: the era tints
rounded-corner pieces with the binary-side PANEL_BACKGROUND_COLOR; this is
that color at the template's exact insets). -->
<Texture name="$parentFlatBg">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-18"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="3"/></Offset></Anchor>
</Anchors>
<Color r="0.122" g="0.118" b="0.129" a="0.8"/>
</Texture>
<!-- The title band behind the top tile (era `_UI-Frame-TitleTileBg`, SharedXML
Classic/SharedUIPanelTemplates.xml l.78: Interface\FrameGeneral\_UI-Frame —
era-only, manifest fdid 374157 — 256x17 horizTile). STRETCHED (SCOPE note). -->
<Texture name="$parentTitleBg" file="era:textures/374157.blp">
<Size><AbsDimension y="17"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="8" y="-3"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
<TexCoords left="0" right="1" top="0.2890625" bottom="0.421875"/>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- The ButtonFrameTemplateNoPortrait nine-slice, unrolled (NineSliceLayouts.lua
l.132 + NineSlice.lua's edge anchoring): corners at their corners at atlas
size; each edge pinned between its two corners on the anchored axis, its
other axis staying the atlas size. The three tile edges + the top band are
STRETCHED (SCOPE note). -->
<Texture name="$parentTopLeftCorner" atlas="UI-Frame-TopLeftCornerNoPortrait" useAtlasSize="true">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture name="$parentTopRightCorner" atlas="UI-Frame-TopCornerRight" useAtlasSize="true">
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="$parentBotLeftCorner" atlas="UI-Frame-BotCornerLeft" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="$parentBotRightCorner" atlas="UI-Frame-BotCornerRight" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<Texture name="$parentTopEdge" atlas="_UI-Frame-TitleTile" useAtlasSize="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTopLeftCorner" relativePoint="TOPRIGHT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentTopRightCorner" relativePoint="TOPLEFT"/>
</Anchors>
</Texture>
<Texture name="$parentBotEdge" atlas="_UI-Frame-Bot" useAtlasSize="true">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="$parentBotLeftCorner" relativePoint="BOTTOMRIGHT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBotRightCorner" relativePoint="BOTTOMLEFT"/>
</Anchors>
</Texture>
<Texture name="$parentLeftEdge" atlas="!UI-Frame-LeftTile" useAtlasSize="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTopLeftCorner" relativePoint="BOTTOMLEFT"/>
<Anchor point="BOTTOMLEFT" relativeTo="$parentBotLeftCorner" relativePoint="TOPLEFT"/>
</Anchors>
</Texture>
<!-- The right edge. The era layout's odd x=-8 / x1=8 pair (NineSliceLayouts
l.139) puts DIFFERENT x on the tile's two corner anchors — the reference
engine draws the quad through its anchor points, so the era tile is a
barely-visible lean (top 8 left, bottom 8 right; mean = flush at the frame
edge, which is exactly how the live window reads — director's capture,
2026-08-04). This engine's regions are axis-aligned rects, so we author the
MEAN: both corners flush (x=0). The first cut gave the whole tile to the
-8 anchor instead — the border floated 8 units into the panel with a dark
gap at the true edge, the director's "right border looks off". -->
<Texture name="$parentRightEdge" atlas="!UI-Frame-RightTile" useAtlasSize="true">
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="$parentTopRightCorner" relativePoint="BOTTOMRIGHT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBotRightCorner" relativePoint="TOPRIGHT"/>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The inner dark panel — the one big era piece, at its full atlas size. -->
<Texture name="$parentInnerFrame" atlas="Options_InnerFrame" useAtlasSize="true">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="17" y="-64"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitleText" inherits="GameFontNormal" text="Options">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- ── Tabs — the era MinimalTabTemplate plates (SharedXML Shared/Tabs/MinimalTab
l.3-46 + the mixin), authored STATIC: Game is the only tab with anything
behind it, so it wears the selected state permanently (Options_Tab_Active_*
plates, white small text lifted to BOTTOM+6) and AddOns wears the unselected
one (Options_Tab_* plates, gold small text at BOTTOM+4 — era's unselected
look, per the director's capture; the button is still dead). The three-slice:
Left/Right caps BOTTOM-anchored at atlas size (7x26 active / 7x23 not, so the
selected plate stands 3 taller), Middle (a 1px column) stretched between. The
era's width law is text + 40, measured at OnLoad with the MEDIUM font before
the small font lands (MinimalTab.lua l.7-9, the quirk kept verbatim) — our
measure round-trip answers a frame late, so OnUpdate polls until the width is
real, the chat-header idiom. -->
<Button name="$parentGameTab">
<Size><AbsDimension x="72" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="32" y="-27"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentLeft" atlas="Options_Tab_Active_Left" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="$parentRight" atlas="Options_Tab_Active_Right" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<Texture name="$parentMiddle" atlas="Options_Tab_Active_Middle" useAtlasSize="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentLeft" relativePoint="TOPRIGHT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRight" relativePoint="TOPLEFT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText" text="Game">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<Scripts>
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
</Scripts>
</Button>
<Button name="$parentAddOnsTab">
<Size><AbsDimension x="87" y="37"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentGameTab" relativePoint="TOPRIGHT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentLeft" atlas="Options_Tab_Left" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="$parentRight" atlas="Options_Tab_Right" useAtlasSize="true">
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<Texture name="$parentMiddle" atlas="Options_Tab_Middle" useAtlasSize="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentLeft" relativePoint="TOPRIGHT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRight" relativePoint="TOPLEFT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText" text="AddOns">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="4"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontNormalSmall"/>
<DisabledFont inherits="GameFontNormalSmall"/>
<!-- No addon list behind it: dead to clicks, but wearing the era's unselected
GOLD (not disabled grey) — the director's capture is the call. -->
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
</Scripts>
</Button>
<!-- ── The category list (era CategoryList, static — it fits) ──────────────────── -->
<Frame name="$parentCategoryList">
<Size><AbsDimension x="199"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="18" y="-76"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="18" y="46"/></Offset></Anchor>
</Anchors>
<!-- The era list rhythm (CreateScrollBoxListLinearView spacing=2 + the 18-tall
SettingsCategoryListSpacerTemplate between groups): 2 between every
element, 2+18+2 = 22 from a group's last row to the next header. -->
<Frames>
<Frame name="$parentHeaderGameplay" inherits="OptionsCategoryHeaderTemplate">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
getglobal(self:GetName() .. "Label"):SetText("Gameplay")
getglobal(self:GetName() .. "Bg"):SetAtlas("Options_CategoryHeader_1", true)
</OnLoad>
</Scripts>
</Frame>
<Button name="$parentRowControls" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentHeaderGameplay" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Controls", "Controls")</OnLoad></Scripts>
</Button>
<Button name="$parentRowInterface" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowControls" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Interface", "Interface")</OnLoad></Scripts>
</Button>
<Button name="$parentRowActionBars" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowInterface" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "ActionBars", "Action Bars")</OnLoad></Scripts>
</Button>
<Button name="$parentRowCombat" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowActionBars" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Combat", "Combat")</OnLoad></Scripts>
</Button>
<Button name="$parentRowSocial" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowCombat" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Social", "Social")</OnLoad></Scripts>
</Button>
<Button name="$parentRowKeybindings" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowSocial" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Keybindings", "Keybindings")</OnLoad></Scripts>
</Button>
<!-- Nameplates — the era Gameplay group's own last row (NAMEPLATE_OPTIONS_LABEL,
order 15), backed by benilla's real nameplate knobs when the pages land. -->
<Button name="$parentRowNameplates" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowKeybindings" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Nameplates", "Nameplates")</OnLoad></Scripts>
</Button>
<Frame name="$parentHeaderSystem" inherits="OptionsCategoryHeaderTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowNameplates" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-22"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
getglobal(self:GetName() .. "Label"):SetText("System")
getglobal(self:GetName() .. "Bg"):SetAtlas("Options_CategoryHeader_2", true)
</OnLoad>
</Scripts>
</Frame>
<Button name="$parentRowGraphics" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentHeaderSystem" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Graphics", "Graphics")</OnLoad></Scripts>
</Button>
<Button name="$parentRowAudio" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowGraphics" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Audio", "Audio")</OnLoad></Scripts>
</Button>
</Frames>
</Frame>
<!-- ── The page area (era SettingsList's header; the body is next slice's) ─────── -->
<Frame name="$parentContainer">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentCategoryList" relativePoint="TOPRIGHT"><Offset><AbsDimension x="16" y="0"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-22" y="47"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentDivider" atlas="Options_HorizontalDivider" useAtlasSize="true">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-50"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- The page title — OptionsFrame_SelectCategory writes it. -->
<FontString name="$parentTitle" inherits="OptionsFontHighlightHuge">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-22"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Disabled this slice: there are no setting rows yet, so there is nothing
to default. Goes live with the pages (delete the Disable, wire the
per-page reset). -->
<Button name="$parentDefaults" inherits="OptionsRedButtonTemplate" text="Defaults">
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-36" y="-16"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>self:Disable()</OnLoad></Scripts>
</Button>
<!-- The page body — EMPTY this slice; the setting rows (Blizzard_SettingControls'
280x26 label/control rows, backed by real benilla knobs) land here next. -->
<Frame name="$parentBody">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-50"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
</Frame>
</Frames>
</Frame>
<!-- ── The search box (era SearchBoxTemplate → InputBoxVisualTemplate,
SecureUIPanelTemplates.xml l.42-67; filters NOTHING yet — SCOPE note): a 20-tall
THREE-slice of common-search-border atlases, vertically centered on the 22 box —
left cap 8x20 hung 5 outside, right cap 8x20 flush, middle stretched between —
plus the 10x10 magnifier at LEFT (1,-1) and a 17x17 clear button at RIGHT (-3)
that appears with text (ClearButtonMixin, cut to our placeholder machinery).
The first cut transcribed the LEGACY nine-piece Common-Input-Border instead —
its ink drew ~27 units tall against the reference's 18 and rode 4 units high:
the director's "search bar is too high" (capture-measured, 2026-08-04). -->
<EditBox name="$parentSearchBox" autoFocus="false" letters="64">
<Size><AbsDimension x="350" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentContainer" relativePoint="TOPRIGHT"><Offset><AbsDimension x="4" y="20"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBorderLeft" atlas="common-search-border-left">
<Size><AbsDimension x="8" y="20"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentBorderRight" atlas="common-search-border-right">
<Size><AbsDimension x="8" y="20"/></Size>
<Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentBorderMiddle" atlas="common-search-border-middle">
<Size><AbsDimension y="20"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorderLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentBorderRight" relativePoint="LEFT"/>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The "Search" placeholder (era instructions label: GameFontDisableSmall
tinted 0.35). OnTextChanged below toggles it on first text. -->
<FontString name="$parentInstructions" inherits="GameFontDisableSmall" justifyH="LEFT" text="Search">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="0"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-20" y="0"/></Offset></Anchor>
</Anchors>
<Color r="0.35" g="0.35" b="0.35"/>
</FontString>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentSearchIcon" atlas="common-search-magnifyingglass">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="1" y="-1"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The clear X (era ClearButtonMixin: shown while text exists, half-alpha
icon, click wipes). Shown/hidden by the box's OnTextChanged below. -->
<Button name="$parentClearButton" hidden="true">
<Size><AbsDimension x="17" y="17"/></Size>
<Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon" atlas="common-search-clearbutton" alpha="0.5">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="3" y="-3"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnClick>
OptionsFrameSearchBox:SetText("")
OptionsFrameSearchBox:ClearFocus()
</OnClick>
</Scripts>
</Button>
</Frames>
<TextInsets><AbsInset left="16" right="20" top="0" bottom="0"/></TextInsets>
<Scripts>
<!-- Placeholder + clear-button toggle only — no filtering behind it yet
(SCOPE note). -->
<OnTextChanged>
if this:GetText() == "" then
OptionsFrameSearchBoxInstructions:Show()
OptionsFrameSearchBoxClearButton:Hide()
else
OptionsFrameSearchBoxInstructions:Hide()
OptionsFrameSearchBoxClearButton:Show()
end
</OnTextChanged>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
<!-- ── Close, both spellings ───────────────────────────────────────────────────── -->
<!-- The X — hung past the top-right corner like the era's ClosePanelButton, on the
1.12 minimize-ball art (header SCOPE note; the LootFrame close idiom). -->
<Button name="$parentClosePanelButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="4" y="4"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts>
<OnClick>
PlaySound("igMainMenuClose")
HideUIPanel(OptionsFrame)
</OnClick>
</Scripts>
</Button>
<!-- The red Close (era SettingsPanel.CloseButton; Apply is deliberately absent — we
apply live, there is never a pending change to hold). -->
<Button name="$parentCloseButton" inherits="OptionsRedButtonTemplate" text="Close">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-16" y="16"/></Offset></Anchor></Anchors>
<Scripts>
<OnClick>
PlaySound("igMainMenuClose")
HideUIPanel(OptionsFrame)
</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<!-- Controls is the default page; a re-opened window keeps its last selection (the
era remembers too — Settings.OpenToCategory's default seat). The scale re-fits
on every show — the era panel manager re-runs checkFit the same way. -->
<OnShow>
OptionsFrame_UpdateScale()
OptionsFrame_SelectCategory(this.selectedCategory or "Controls")
</OnShow>
</Scripts>
</Frame>
</Ui>
+31 -8
View File
@@ -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)
+87
View File
@@ -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/<fdid>.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<String, Member>,
}
#[derive(Deserialize)]
struct Member {
/// Relative to the extraction root (`textures/<fdid>.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<Vec<(String, EraAtlasEntry)>> {
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(),
)
}
+20
View File
@@ -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<Image>,
) -> Option<Handle<Image>> {
// `era:<relpath>` (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.
+12
View File
@@ -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
+26
View File
@@ -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<f32>,
/// 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<f64>,
}
/// `$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
+15
View File
@@ -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`.
+431
View File
@@ -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<String, String>,
}
/// 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<String, String>,
/// Lowercased names whose value came from an env var this session (never saved).
env_overridden: HashSet<String>,
/// 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<Instant>,
}
/// 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::<CvarPersist>()
.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::<f32>() 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<CvarPersist>,
mut sound: ResMut<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
) {
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<String> = 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<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut sound: ResMut<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
) {
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<String, String>,
env_overridden: &HashSet<String>,
snapshot: &[(String, String, String)],
) -> BTreeMap<String, String> {
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<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut exits: MessageReader<AppExit>,
) {
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::<f32>().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<String, String> = [
("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<String> = ["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::<SoundConfig>().music, 0.1);
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.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::<UiScript>()
.run(r#"SetCVar("MusicVolume", 0.75)"#)
.unwrap();
app.update();
assert_eq!(app.world().resource::<SoundConfig>().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"));
}
}
+150
View File
@@ -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. **`<install root>/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<PathBuf> {
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<PathBuf> {
home().map(|h| h.join("config.toml"))
}
/// Write a state file atomically: parent dirs created, contents to `<path>.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<std::ffi::OsString>);
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();
}
}
+28 -10
View File
@@ -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)
@@ -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::<bool>("return OptionsFrame:IsVisible()").unwrap());
assert!(
!s.eval::<bool>("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::<bool>("return OptionsFrame:IsVisible()").unwrap(),
"ESC closed the options window"
);
assert!(
!s.eval::<bool>("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::<bool>("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
+39 -9
View File
@@ -66,9 +66,13 @@ pub(super) fn drive_script(
// This frame's `<Minimap>` 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<crate::minimap::MinimapWidget>,
// 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<bool>,
// 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<f32>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── 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);
+18 -6
View File
@@ -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.
+77 -35
View File
@@ -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::<f64>("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::<f64>("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::<bool>(&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::<bool>(&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::<String>("return GameMenuButtonAddOns:GetText()")
.unwrap(),
"AddOns"
);
assert_eq!(
s.eval::<String>("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::<String>("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::<String>("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::<f64>("return BENILLA_MENU_ONSHOW or -99").unwrap();
println!("handler ran probe: {ran}");
let k = s.eval::<f64>("return GameMenuFrame:GetScale()").unwrap();
let want = s.eval::<f64>("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}");
}
+33 -3
View File
@@ -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::<crate::capture::CaptureMode>();
@@ -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<NonSendMut<UiScript>>) {
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<String> {
// 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;
@@ -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::<bool>("return OptionsFrame:IsVisible()").unwrap(),
"the options window opened"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and the menu went away first"
);
assert!(
s.eval::<bool>("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::<String>("return OptionsFrame.selectedCategory")
.unwrap(),
"Controls"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
"Controls"
);
// The row labels are the era category tree's.
assert_eq!(
s.eval::<String>("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::<String>("return OptionsFrame.selectedCategory")
.unwrap(),
"Graphics"
);
assert_eq!(
s.eval::<String>("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::<String>("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::<bool>("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::<bool>("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::<bool>("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::<String>("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::<f64>("return OptionsFrameCategoryListRowControlsBg:GetWidth()")
.unwrap(),
187.0
);
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
assert!(
s.eval::<bool>("return OptionsFrameCategoryListRowControlsBg:GetAtlas() == nil")
.unwrap(),
"the old row stripped bare when the selection moved"
);
assert_eq!(
s.eval::<String>("return OptionsFrameCategoryListRowAudioBg:GetAtlas()")
.unwrap(),
"options_list_active"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}