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:
Generated
+53
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
[](https://discord.gg/wJSJx467G4)
|
||||
[](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8)
|
||||
[](#license)
|
||||
> [!IMPORTANT]
|
||||
> **Issues and pull requests are closed here.** benilla is a solo project developed in a private
|
||||
> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land
|
||||
> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report
|
||||
> the bugs you find. Questions and ideas are welcome in the same place.
|
||||
|
||||
A from-scratch **World of Warcraft 1.12.1 client** in **Rust + [Bevy](https://bevy.org)**, speaking
|
||||
the vanilla protocol to a [vmangos](https://github.com/vmangos/core) server. Every file format and
|
||||
the network protocol are implemented from scratch — no original client code, no third-party WoW
|
||||
crates, and no bundled game assets: benilla reads its data at runtime from your own 1.12.1 install.
|
||||
benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could,
|
||||
and reads its game data at runtime from your own 1.12.1 install. Every file format and the network
|
||||
protocol are implemented from scratch, with no original client code, no third-party WoW crates,
|
||||
and no bundled game assets.
|
||||
|
||||
## What works
|
||||
|
||||
- **Formats & assets** — in-repo readers for the full asset stack (MPQ patch chain, BLP, DBC,
|
||||
ADT/WDT/WDL, M2, WMO), wired into Bevy as an asset source, plus extraction CLI tools.
|
||||
- **World** — streamed terrain with the distant horizon, portal-culled WMOs with interior lighting,
|
||||
- **Formats:** readers for the full asset stack (MPQ patch chain, BLP, DBC, ADT/WDT/WDL, M2, WMO),
|
||||
wired into Bevy as an asset source.
|
||||
- **World:** streamed terrain out to the horizon, portal-culled WMOs with interior lighting,
|
||||
doodads and ground clutter, swimmable liquids, sky and weather, and the client's own day/night
|
||||
lighting, fog, glow, and gamma passes.
|
||||
- **Models & animation** — GPU-skinned M2s with the full animation controller (cross-fades,
|
||||
one-shots, fidgets), a near feature-complete particle system, ribbons, and animated gameobjects
|
||||
(doors, chests, mailboxes, lifts).
|
||||
- **Characters** — customization end to end, the armor-region texture composite, held weapons with
|
||||
sheathing and enchant glows, shapeshift forms, stealth's fade, and mounts.
|
||||
- **Movement & travel** — a WoW-feel controller (run/strafe/jump/swim/step-up), networked movement
|
||||
in both directions, the server-granted modes (slow fall, levitate, water walking, roots and
|
||||
stuns), a follow-camera with collision, ridden boats and zeppelins, and taxi flights.
|
||||
- **Networking** — SRP6 auth through world-session crypto, the object-descriptor mirror into the
|
||||
ECS, and live wire coverage for movement, chat, spells and auras, party, duels, inspect, the
|
||||
social lists, mail, trade, vendors, bank, trainers, professions, quests, loot with group rolls,
|
||||
item use and locks, and death.
|
||||
- **UI** — a from-scratch FrameXML + Lua UI engine driving the built-in interface: login and
|
||||
character screens, the full HUD (cast bars, breath and fatigue timers, combo points), the classic
|
||||
windows (bags and keyring, spellbook, talents, quests, professions, social, inspect, mail,
|
||||
merchant, trade, bank, world map, …), chat with its slash commands, nameplates, floating combat
|
||||
text, chat bubbles, and tooltips.
|
||||
- **Combat** — melee with the faithful swing law, ranged and Auto Shot, casting with GCD, cooldowns
|
||||
and the cancel ladder, combo points, ground-targeted casting, crowd control that really holds you
|
||||
(roots, stuns, the Ice Block freeze), and the spell-visual pipeline (kits, missiles, impacts,
|
||||
ground rings).
|
||||
- **Audio** — music, ambience, and SFX under the client's own selection and crossfade rules, with
|
||||
interior/underwater transitions and zone reverb.
|
||||
lighting, fog and gamma passes.
|
||||
- **Models:** GPU-skinned M2s with the full animation controller, a near feature-complete particle
|
||||
system, ribbons, and animated gameobjects from doors to lifts.
|
||||
- **Characters:** customization end to end, the armor texture composite, weapons with sheathing and
|
||||
enchant glows, shapeshift forms, stealth and mounts.
|
||||
- **Movement:** a WoW-feel controller, networked movement in both directions, the server-granted
|
||||
modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights.
|
||||
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
|
||||
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank
|
||||
and loot.
|
||||
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
|
||||
character screens through the full HUD, the classic windows, chat, nameplates, floating combat
|
||||
text and tooltips.
|
||||
- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and
|
||||
cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline.
|
||||
- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with
|
||||
interior and underwater transitions and zone reverb.
|
||||
|
||||
## Not built yet
|
||||
|
||||
Guilds (past the name over a head), the auction house, hunter/warlock pet control (the pet bar),
|
||||
battlegrounds, honor and the zone-control PvP overlays (the PvP flag itself shows), the fishing
|
||||
minigame, camera-possession spells (Mind Vision, Sentry Totem), the try-on preview (dressing
|
||||
room), the macros/options/key-binding screens, and third-party addon loading — the Lua/FrameXML
|
||||
engine currently runs only the built-in UI.
|
||||
Guilds past the name over a head, the auction house, the hunter and warlock pet bar, battlegrounds
|
||||
and honor, the fishing minigame, the dressing room, macros and key bindings, and third-party
|
||||
addons; the Lua engine runs only the built-in UI so far.
|
||||
|
||||
## Running it
|
||||
|
||||
You need a **1.12.1 (build 5875) client install** for game data, a
|
||||
[vmangos](https://github.com/vmangos/core) server to connect to, and stable Rust.
|
||||
You need a **1.12.1 (build 5875) client install** for game data, a vanilla server to connect to,
|
||||
and stable Rust. Any 1.12.1 core works; [vmangos](https://github.com/vmangos/core) is what
|
||||
development runs against, and cMaNGOS and the rest speak the same protocol.
|
||||
|
||||
```sh
|
||||
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
|
||||
```
|
||||
|
||||
The server defaults to `localhost:3724`, where a stock vmangos `realmd` listens — point `WOW_HOST`
|
||||
at any IP or hostname, and append an auth port if yours is remapped: `WOW_HOST=play.example.com:5000`.
|
||||
Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it.
|
||||
The server defaults to `localhost:3724`, the stock `realmd` auth port. Point `WOW_HOST`
|
||||
at any IP or hostname, appending the auth port if yours is remapped
|
||||
(`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` /
|
||||
`WOW_PASS` to skip it.
|
||||
|
||||
## Contributing
|
||||
---
|
||||
|
||||
benilla is a solo project and isn't taking pull requests for now. Bug reports and questions are
|
||||
welcome — bring them to the [Discord](https://discord.gg/wJSJx467G4) (issues are disabled here on
|
||||
purpose). Comments throughout the code cite numbered
|
||||
decision records and method notes from a private development log; the citations won't resolve
|
||||
here, but the reasoning they carry is kept in the comments themselves.
|
||||
Early inspiration and file format guidance came from the
|
||||
[wowemulation-dev](https://github.com/wowemulation-dev) community, and
|
||||
[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular.
|
||||
|
||||
Development happens in that private tree, and this repo is its published export: **each commit here
|
||||
is one squashed snapshot** of everything that landed since the last one, titled with what changed —
|
||||
not an atomic change, which is why they're large and why there are few of them.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
The [wowemulation-dev](https://github.com/wowemulation-dev) community — and
|
||||
[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular — was an early
|
||||
inspiration and a source of guidance on the client's file formats. benilla's readers ended up
|
||||
written from scratch, but their preservation work made starting far less daunting. And none of
|
||||
this runs without [vmangos](https://github.com/vmangos/core) on the other end of the socket.
|
||||
|
||||
## Legal
|
||||
|
||||
benilla is an independent, fan-made project, not affiliated with or endorsed by Blizzard
|
||||
Entertainment. It contains no Blizzard code or assets and never will; you must provide your own
|
||||
legally-obtained 1.12.1 client for game data. World of Warcraft is a trademark of Blizzard
|
||||
Entertainment, Inc.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of the [Apache License 2.0](LICENSE-APACHE) or the [MIT
|
||||
license](LICENSE-MIT), at your option.
|
||||
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
|
||||
It contains no Blizzard code or assets, and you provide your own legally obtained 1.12.1 client
|
||||
for game data. World of Warcraft is a trademark of Blizzard Entertainment, Inc. Licensed under
|
||||
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user