diff --git a/crates/benilla-ui/examples/dump_globals.rs b/crates/benilla-ui/examples/dump_globals.rs index b85f510f..1ff2df50 100644 --- a/crates/benilla-ui/examples/dump_globals.rs +++ b/crates/benilla-ui/examples/dump_globals.rs @@ -1,40 +1,17 @@ -//! `dump_globals` — print benilla's Lua global namespace, asked of a real VM. +//! `dump_globals`: print benilla's Lua global namespace, asked of a real VM. //! //! ```text //! cargo run -q -p benilla-ui --example dump_globals # nametype, one per line -//! cargo run -q -p benilla-ui --example dump_globals --members # ...the stdlib TABLES' members too +//! cargo run -q -p benilla-ui --example dump_globals --members # plus the stdlib tables' members //! ``` //! -//! **The point is that it is a run, not a grep** (decisions 1188, 1189). Every wrong number in the -//! addon arc came from measuring our API surface by pattern-matching Rust source: a regex over -//! `.set("Name", …)` misses the Lua prelude, miscounts a `format!`-registered family, and cannot -//! see what the sandbox removed. When the question is what a running system exposes, ask the -//! running system — this is the four-minute answer 1188 §4 describes, made permanent. -//! -//! This is [`UiScript::new`]'s namespace: the Rust bindings plus the Lua stdlib prelude, i.e. what -//! an addon sees *before* any FrameXML loads. That is deliberately the engine half — it is -//! deterministic, needs no install, and is exactly what `scripts/api-coverage.sh` compares against -//! `reference/1.12-globals.tsv`'s `engine` and `lua` rows. -//! -//! ## `--members`, and the blind spot it closes (decision 1194) -//! -//! `_G` is not the whole surface an addon can tell apart. `table.setn` is not a global — it is a -//! member of the `table` table — and it stopped **61 of 218** real addons dead, because mlua's Lua -//! 5.1 raises `'setn' is obsolete` where the 1.12 client's Lua 5.0 does the thing. A `_G`-only -//! instrument cannot see that, and did not: the arc measured coverage for two sessions with this -//! gap wide open. `--members` prints `table.setn`-style rows for the stdlib tables, so the dialect -//! is measurable the same way the API surface is. -//! -//! It also prints the **per-type metatables** (decision 2171), which is the same blind spot one -//! layer further down and stayed open longer because a metatable is not a *name*: 5.1 gives the -//! string type one and 5.0 cannot have one, so `("x"):upper()` worked here and raised on the -//! reference for as long as nothing thought to look. +//! The namespace is [`UiScript::new`]'s, the engine half before any FrameXML loads, which +//! `scripts/api-coverage.sh` compares with `reference/1.12-globals.tsv`. `--members` adds what `_G` +//! cannot show: the stdlib tables' members (`table.setn` is one) and the per-type metatables. use benilla_ui::script::UiScript; -/// The tables whose membership an addon can observe and depend on. -/// -/// Deliberately a fixed list, not "every table-valued global": `_G` would recurse, and a *frame* -/// table's members are the widget API, which is a different question with a different reference. +/// The stdlib tables whose members an addon can observe: a fixed list, since `_G` would recurse +/// and a frame's members are the widget API. const STDLIB: &[&str] = &["string", "table", "math", "coroutine", "os", "io", "debug"]; fn main() -> mlua::Result<()> { @@ -66,18 +43,9 @@ fn main() -> mlua::Result<()> { rows.extend(members); } - // **The per-type metatables — the surface `_G` and the member lists both miss** (decision - // 2171). `table.setn` taught 1194 that a `_G`-only instrument cannot see a *member*; the - // string metatable is the same lesson one layer further down, and it stayed invisible for - // longer because it is not a name at all. 1.12 installs none — `lua_setmetatable 0x6f4020` - // accepts only LUA_TTABLE and LUA_TUSERDATA and returns 0 for every other tag, so a - // primitive type there *cannot* carry one — while stock 5.1 gives `string` a metatable - // whose `__index` is the `string` table itself, which is what makes `("x"):upper()` work - // on 5.1 and raise on 5.0. Every row here should read `nil`. - // - // Written as a fixed list rather than a table because a `nil` value has no entry to - // iterate — the one type whose metatable slot is easiest to forget is the one a - // constructor silently drops. + // The per-type metatables. Every row should read `nil`: 1.12's `lua_setmetatable` + // (`0x6f4020`) takes only tables and userdata, where 5.1 gives strings one whose `__index` + // makes `("x"):upper()` work. Probed one by one, since a `nil` has no table entry. let metatables: Vec = script.eval( "local out = {} \ local function probe(name, v) \ diff --git a/crates/benilla-ui/examples/dump_widget_methods.rs b/crates/benilla-ui/examples/dump_widget_methods.rs index ad0d3dc7..27550a20 100644 --- a/crates/benilla-ui/examples/dump_widget_methods.rs +++ b/crates/benilla-ui/examples/dump_widget_methods.rs @@ -1,13 +1,10 @@ -//! `dump_widget_methods` — print benilla's widget METHOD surface, asked of a real VM. +//! `dump_widget_methods`: print benilla's widget method surface, asked of a real VM. //! //! ```text //! cargo run -q -p benilla-ui --example dump_widget_methods # classmethod, one per line //! ``` //! -//! The sibling of [`dump_globals`](../dump_globals.rs). The measurement itself lives in -//! `benilla_ui::script::widget_method_census` — its module doc is the *why*, and the widget-surface -//! gate (decision 2142) reads the same function, so the instrument you look at by hand and the gate -//! that fails the build can never disagree about what our surface is. +//! The census is `widget_method_census`, the same function the widget-surface gate reads. use benilla_ui::script::{widget_method_census, UiScript}; fn main() -> mlua::Result<()> { diff --git a/crates/benilla-ui/src/bindings_xml.rs b/crates/benilla-ui/src/bindings_xml.rs index 0a714285..895c9d3e 100644 --- a/crates/benilla-ui/src/bindings_xml.rs +++ b/crates/benilla-ui/src/bindings_xml.rs @@ -1,107 +1,37 @@ -//! **`Bindings.xml`** — an addon's key-binding declaration (decision 1188 phase 4). +//! An addon's `Bindings.xml`, its key-binding declarations. The reference loads one per addon in +//! `AddOn_Load 0x51f240`, after the addon's `.toc` files (`0x51f3fa`) and before its saved +//! variables (`0x51f400` to `0x51f4b5`). The stock file has 228 live bindings (94 `runOnUp`, 13 +//! `header`, 12 `hidden`, 9 `debug`, 5 `platform`); the six `MOVEVIEW*` ones sit in an XML comment. //! -//! The reference loads one per addon, at a verified position inside `AddOn_Load 0x51f240`: -//! **after** that addon's `.toc`-listed files (`0x51f3fa`) and **before** its saved-variables -//! files (`0x51f400` → `0x51f4b5`). Both of benilla's load paths -//! attach there — the startup walk (`benilla_app::ui_script::addons`) and the demand load -//! ([`crate::script::addon`]) — and what they parse here feeds the same table the Key Bindings -//! window edits ([`crate::script::keybind`]). -//! -//! ## The format, read off the client's own file, not remembered -//! -//! `Interface\FrameXML\Bindings.xml` (extracted 1.12.1) is **228 live `` elements**, -//! every one named, and the whole schema it uses is six attributes: `name` (228), `runOnUp` (94), -//! `header` (13), `hidden` (12), `debug` (9), `platform` (5). -//! -//! *Live*, because a text search says 234: six more sit inside an XML comment — Blizzard commented -//! out the whole `MOVEVIEW*` family in place. A parser that finds bindings by scanning text (and -//! any count taken by `grep`) registers those six as real, which is why the counts here are the -//! ones this module's own parse returns and why `a_commented_out_binding_is_not_a_binding` exists. -//! The counts are pinned against the install itself by -//! `benilla_app::bindings::commands::tests::the_installs_bindings_xml_reads_as_the_parsers_header_says` -//! — there, because there is where the patch chain is (this crate reads no install). -//! -//! ```xml -//! -//! -//! if ( keystate == "down" ) then MoveForwardStart(); else MoveForwardStop(); end -//! -//! -//! ``` -//! -//! - **The body is ONE Lua chunk, run once or twice.** A `runOnUp="true"` binding runs on the -//! press *and again* on the release, with the global `keystate` set to `"down"` / `"up"` — which -//! is why every shipped `runOnUp` body is an `if ( keystate == "down" )` fork. A binding without -//! it runs once, on the press. (Our own registry says the same thing a second way: -//! `benilla_app::bindings::commands::Kind::EdgeUpDown` holds the two halves as two strings, -//! because a host command's halves are ours to write. An addon's is one string and a global.) -//! - **`header` is a GLOBAL-STRING KEY, and it opens a SECTION.** `header="MOVEMENT"` means the -//! category `BINDING_HEADER_MOVEMENT`, and every following binding *without* a `header` belongs -//! to it: 13 headers over 228 bindings is 13 sections, and the client's own list is flat with -//! `HEADER_*` pseudo-entries in it (`Blizzard_BindingUI.lua:87` tests -//! `strsub(commandName, 1, 6) == "HEADER"`). The carry-forward is applied at *registration*, not -//! here, so this type stays a faithful record of what the file says — see -//! [`crate::script::UiScript::register_addon_bindings`]. -//! - **`hidden="true"` keeps a binding out of the Key Bindings window** while leaving it bindable -//! and dispatchable — 1.12 uses it for the debug toggles and for the three mouselook bindings -//! (`TURNORACTION`, `CAMERAORSELECTORMOVE`, …) that the mouse owns rather than the player. -//! - **`platform="mac"` is the file's own OS gate**, and its five uses are the whole of it: the -//! `ITUNES_REMOTE` block, which remotes the *system* music player and therefore exists only in -//! the Mac build. It is carried on [`AddonBinding::platform`] and acted on at registration -//! ([`crate::script::UiScript::register_addon_bindings`]) — a row for another platform is not -//! registered at all, so it never reaches the Key Bindings window, a save file, or a chord. -//! Registering it instead would list a command whose body calls functions this build does not -//! have, which is a row that can only ever error on its first press. -//! - `debug="true"` is **read and not acted on**: it appears only on the client's own nine -//! hidden dev toggles, always together with `hidden="true"` (which IS acted on and is what -//! keeps them out of the window), and no addon in the wild uses it. Recording that it exists is -//! the point; inventing a second meaning for it would be a guess. -//! -//! ## Why this lives beside `toc` and `framexml` rather than under `script/` -//! -//! It is the third of the client's **file formats**, and the crate's top level is where a format -//! becomes owned data with no VM in sight: [`crate::toc`] turns manifest text into a load list, -//! [`crate::framexml`] turns document text into a tree, and this turns binding text into -//! [`AddonBinding`]s. `script/` is the other side of that line — the live Lua-facing runtime, -//! which *consumes* what these three produce. A parser under `script/keybind` would also be -//! unreachable from the host's own load path without going through the VM, which is exactly the -//! shape 1186 spent a decision untangling. -//! -//! XML *parsing* is delegated plumbing here for the same reason it is in [`crate::framexml`] — -//! `roxmltree` reads the bytes, and only the schema→meaning map below is ours. +//! A body is one Lua chunk; with `runOnUp="true"` it runs on press and again on release, the +//! global `keystate` set to `"down"` or `"up"`. `header="X"` opens the section +//! `BINDING_HEADER_X` that following header-less bindings join, in a flat list with `HEADER` +//! pseudo-entries (`Blizzard_BindingUI.lua:87`). `hidden="true"` keeps a binding bindable but out +//! of the Key Bindings window. `platform="mac"` marks the `ITUNES_REMOTE` rows; the reference +//! skips another platform's row at load (`0x4b70c3`–`0x4b70e5`), benilla at registration. use std::fmt; -/// One `` element, exactly as the file states it. -/// -/// Deliberately a *record of the file*, not of the registered binding: `header` is the raw -/// attribute (`None` when the element carries none, which is 221 of the reference's 234), and the -/// section carry-forward and the `BINDING_HEADER_` prefixing happen where the addon's identity is -/// known — [`crate::script::UiScript::register_addon_bindings`]. (`None` for 215 of the -/// reference's 228 rows, which is what makes the carry-forward the whole story rather than a -/// detail.) +/// One `` element as the file states it; the section carry-forward and the +/// `BINDING_HEADER_` prefix are applied by [`crate::script::UiScript::register_addon_bindings`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AddonBinding { - /// The `name` attribute — the command name `SetBinding`/`GetBindingKey` speak in. + /// The command name `SetBinding` and `GetBindingKey` speak in. pub name: String, - /// The `header` attribute's global-string *suffix* (`MOVEMENT` → `BINDING_HEADER_MOVEMENT`), - /// when this element opens a section. + /// The raw `header` suffix (`MOVEMENT` of `BINDING_HEADER_MOVEMENT`), opening a section. pub header: Option, - /// `runOnUp="true"` — the body runs on the press **and** on the release (see the module doc). + /// `runOnUp="true"`: the body runs on press and on release. pub run_on_up: bool, - /// `hidden="true"` — bindable and dispatchable, but not listed in the Key Bindings window. + /// `hidden="true"`: bindable, but not listed in the Key Bindings window. pub hidden: bool, - /// `platform="mac"` — the row belongs to one build only, lower-cased. `None` on every row - /// that carries no such attribute, which is every row but five. + /// `platform=`, lower-cased: the one build the row belongs to. pub platform: Option, - /// The element's own text: one Lua chunk, verbatim (entities already decoded, so a body that - /// wrote `<` arrives as `<`). + /// The element's own text, one Lua chunk, entities decoded (`<` arrives as `<`). pub body: String, } -/// A malformed document. Everything softer — an element with no `name`, an unknown attribute, an -/// unexpected root — is tolerated the way the real loader tolerates it; only bytes that are not -/// XML stop the file. +/// Bytes that are not XML. A nameless element, an unknown attribute or an unexpected root is +/// tolerated, as the reference's loader tolerates them. #[derive(Debug)] pub enum Error { Xml(roxmltree::Error), @@ -123,23 +53,13 @@ impl std::error::Error for Error { } } -/// Parse `Bindings.xml` text into its bindings, in document order. -/// -/// **Every `` under the root**, at any depth and matched case-insensitively like every -/// other element compare in this crate (`0x64a4c0`, see [`crate::framexml`]'s header). The shipped -/// root is `` and the elements are its direct children, but a descendant walk costs -/// nothing and cannot over-collect — a `` never nests — while it does keep an addon that -/// wraps its bindings in a `` root (or ships them inside a `` inside one) working. -/// -/// **A `` with no `name` is skipped**: there is nothing for `SetBinding` to name and -/// nothing for the reference's record to key on. Defensive rather than observed — all 228 of the -/// client's own are named. +/// Parse `Bindings.xml` text into its bindings, in document order: every `` at any +/// depth, the tag matched case-insensitively (`0x64a4c0`), skipping one with no `name`. The +/// reference reads only the root's children (`0x4b701f`) and also skips a row with an empty body +/// (`0x4b7093`–`0x4b709e`) or `debug="true"` (`0x4b70a4`–`0x4b70bd`), which this keeps. pub fn parse(text: &str) -> Result, Error> { - // Namespace-oblivious, like the FrameXML document layer beside it and for the same reason - // (decision 2155; `framexml::parse`'s comment is the mechanism): a `Bindings.xml` is read by - // the same `XMLTree.cpp` tree the client builds with expat's `XML_ParserCreate`, which has no - // namespace processing at all — so an undeclared prefix cannot be an error there, and must not - // cost the whole file here. + // The reference's expat has no namespace processing, so an undeclared prefix is bound and the + // text re-read rather than costing the file, as in `framexml::parse`. let repaired; let doc = match roxmltree::Document::parse(text) { Ok(doc) => doc, @@ -171,25 +91,21 @@ pub fn parse(text: &str) -> Result, Error> { Ok(out) } -/// Case-insensitive attribute lookup — Blizzard's own XML is inconsistent about attribute casing -/// and the real loader's `GetAttribute 0x6f2cf0` folds case ([`crate::framexml`]). +/// Case-insensitive attribute lookup, as the reference's `GetAttribute 0x6f2cf0` folds case. fn attr_ci(node: roxmltree::Node, name: &str) -> Option { node.attributes() .find(|a| a.name().eq_ignore_ascii_case(name)) .map(|a| a.value().to_string()) } -/// A boolean attribute: true **iff** present and equal to the literal `"true"`, case-insensitively -/// — the client's own rule (`0x6f1b30`'s true-cmp; there is no `"false"` branch, so `runOnUp="1"` -/// is false exactly as it is in the real loader). +/// True only for a case-insensitive `"true"` (`0x6f1b30` has no `"false"` branch), so +/// `runOnUp="1"` is false. fn attr_bool(node: roxmltree::Node, name: &str) -> bool { attr_ci(node, name).is_some_and(|v| v.eq_ignore_ascii_case("true")) } -/// The node's own direct text/CDATA children, concatenated — the binding's Lua chunk. Does not -/// descend into child elements, and so drops the `` comments the shipped file is full -/// of. Concatenation (rather than the first text node) is what keeps a body containing an entity -/// whole: `a < b` arrives from `roxmltree` as three text nodes. +/// The node's direct text and CDATA, concatenated: `roxmltree` splits `a < b` into three text +/// nodes. fn direct_text(node: roxmltree::Node) -> String { node.children() .filter(|n| n.is_text()) @@ -201,15 +117,6 @@ fn direct_text(node: roxmltree::Node) -> String { mod tests { use super::*; - /// **The reference's own shape**, element for element: the `runOnUp` + `header` opener with a - /// multi-line `keystate` fork, a plain one-shot binding, a `hidden` debug toggle, and — the - /// one case a hand-written addon file actually hits — an entity inside a body. - /// - /// What this catches: a parser that reads only the first text node (the `<` body comes back - /// truncated at the entity), one that treats `runOnUp="1"`/absent as true, one that swallows - /// the newlines a Lua chunk needs to stay a Lua chunk, and one that hands back the `header` - /// already prefixed (the prefixing is registration's job — a doubled - /// `BINDING_HEADER_BINDING_HEADER_MOVEMENT` is the failure that would follow). #[test] fn the_reference_shape_parses_attribute_for_attribute() { let binds = parse( @@ -270,8 +177,7 @@ mod tests { "the body is a Lua chunk — its newlines are load-bearing" ); - // A binding with no header of its own carries none: the section it belongs to is a - // property of the LIST, resolved at registration. + // Section membership is resolved at registration, not here. assert_eq!(binds[1].name, "JUMP"); assert_eq!(binds[1].header, None); assert!(!binds[1].run_on_up); @@ -279,23 +185,18 @@ mod tests { assert!(binds[2].hidden, "hidden=\"true\" is recorded, not dropped"); assert!(!binds[2].run_on_up); - // Attribute name AND value fold case (the loader's own compares do); the entity is - // decoded and the body around it survives whole. + // Attribute names and values fold case; the entity decodes and the body stays whole. assert!(binds[3].run_on_up, "RUNONUP=\"TRUE\" is runOnUp"); assert_eq!(binds[3].body.trim(), "if ( a < b ) then Probe(); end"); - // ...but only the literal `true` is true — there is no `"false"` branch in the client's - // parse, so `"1"` is simply not it. assert!( !binds[4].run_on_up, "runOnUp=\"1\" is false: the client's bool compares against \"true\" alone" ); } - /// A nameless `` is skipped rather than registered under `""` — where it would eat - /// `GetBindingAction`'s "no command is bound to this key" answer, which IS the empty string. - /// A `` nested under a wrapper element still counts, so an addon that wraps its - /// bindings does not silently lose them. + /// Registered under `""`, a nameless binding would shadow `GetBindingAction`'s empty answer + /// for an unbound key. #[test] fn nameless_bindings_are_skipped_and_nesting_is_tolerated() { let binds = parse( @@ -312,11 +213,6 @@ mod tests { assert_eq!(binds[0].name, "PROBEWRAPPED"); } - /// **A commented-out `` is not a binding** — and this is not a hypothetical: the - /// client's own file carries the entire `MOVEVIEW*` family (six bindings) inside one XML - /// comment, which is the whole gap between the 234 elements a text search finds and the 228 - /// the loader sees. Registering them would put six commands in the Key Bindings window that - /// the client does not have, each one a key a player could bind to nothing at all. #[test] fn a_commented_out_binding_is_not_a_binding() { let binds = parse( @@ -339,9 +235,6 @@ mod tests { ); } - /// Bytes that are not XML stop the file — the one hard error. The addon's other files have - /// already run by then (`Bindings.xml` loads after them, `0x51f400`), so the load path reports - /// this and carries on; what it must not do is silently register nothing. #[test] fn malformed_xml_is_an_error() { let e = parse("").expect_err("malformed"); diff --git a/crates/benilla-ui/src/civil.rs b/crates/benilla-ui/src/civil.rs index c050a795..a919a1a7 100644 --- a/crates/benilla-ui/src/civil.rs +++ b/crates/benilla-ui/src/civil.rs @@ -1,14 +1,6 @@ -//! **The tree's one civil-time conversion** — broken-down UTC from an epoch second. -//! -//! It lives here because this crate already had to have one: 1.12's `date()` global is Lua 5.0's -//! `os.date` hoisted, and the sandbox strips `os` ([`crate::script`]'s stdlib), so the calendar -//! had to be written by hand. The screenshot writer is its second caller (decision 1487), and one -//! correct closed form with two callers beats two hand-rolled loops. -//! -//! **UTC, deliberately, and it is a stated divergence wherever a caller wanted local time.** The -//! workspace has no date dependency at all — every format crate in it is in-repo — and resolving a -//! local offset needs a timezone source, not a different algorithm. `date()` records the same -//! divergence for the same reason. +//! Broken-down UTC time from an epoch second, the calendar behind `date()` and screenshot names. +//! Deviation: UTC where the reference's callers use local time, because the workspace has no +//! timezone source. /// Broken-down UTC time. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -27,12 +19,8 @@ pub struct Civil { pub yearday: u32, } -/// Break an epoch second down into UTC fields. -/// -/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap -/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop -/// because it is a known-correct closed form with no accumulation error, which matters for the -/// "persisted last session, compared this session" use every corpus `date()` caller has. +/// Break an epoch second down into UTC fields, by Howard Hinnant's civil-from-days: years count +/// from month 3, so the leap day falls last and no month-length table is needed. pub fn from_unix(secs: i64) -> Civil { let days = secs.div_euclid(86_400); let rem = secs.rem_euclid(86_400); @@ -55,7 +43,6 @@ pub fn from_unix(secs: i64) -> Civil { let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = if month <= 2 { y + 1 } else { y }; - // Day of the year, from the same conversion applied to Jan 1. let yearday = (days - days_from_civil(year, 1, 1) + 1) as u32; Civil { year, @@ -69,7 +56,6 @@ pub fn from_unix(secs: i64) -> Civil { } } -/// Days since the epoch for a civil date — the inverse of the above, used only for `yearday`. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = y.div_euclid(400); @@ -80,8 +66,7 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { era * 146_097 + doe - 719_468 } -/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in; a -/// clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope. +/// Wall-clock seconds since the Unix epoch; a clock set before 1970 reads 0 rather than raising. pub fn unix_seconds() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -93,8 +78,7 @@ pub fn unix_seconds() -> i64 { mod tests { use super::*; - /// Four instants that between them cover the epoch itself, a leap day, a century boundary and - /// an ordinary afternoon — the cases a closed-form conversion gets wrong when it is wrong. + /// The epoch, an ordinary afternoon, a leap day and the eve of 2000. #[test] fn known_instants_break_down() { let cases = [ @@ -121,8 +105,7 @@ mod tests { } } - /// Before 1970 is not a state a client is in, but the arithmetic must not wrap into nonsense - /// if a clock reports it — `div_euclid`/`rem_euclid` are load-bearing here, not stylistic. + /// `div_euclid`/`rem_euclid` keep a second before 1970 from wrapping into nonsense. #[test] fn negative_seconds_stay_civil() { assert_eq!( diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index 7720333f..957e8e3a 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -1,44 +1,25 @@ -//! The FrameXML **document** layer (decision 0068): turns a `.xml` file's text into an owned, -//! order-preserving tree, resolves `virtual`/`inherits` template expansion, and rewrites `$parent` -//! name tokens. This is XML-tree semantics only — no widget instantiation (frame factories, anchor -//! resolution, script compilation) and no Lua; those live in the layer above, which walks the tree -//! this module produces. -//! -//! Ground truth is the real client's loader (`0x6edc00–0x6f3000`): -//! - the file loader `0x6ede10` — the schema → frame-op interpretation, the full top-level + -//! per-element op map -//! - `LoadChildFrames 0x76a060` — `` / load-order (widget-layer concern; cited -//! here only for the "spliced before the instance's own nodes" ordering that also governs template -//! `` children, which this module's generic child-concat already gets right) -//! - `0x76c5b0` — the `$parent` substitution rule -//! -//! XML *parsing* is delegated plumbing in the reference too (an embedded expat); the schema→op map -//! is its own — we use `roxmltree` rather than reproduce a tokenizer; only the schema→op -//! interpretation below is transcribed from the reference. +//! The FrameXML document layer: a `.xml` file's text as an owned, order-preserving tree, with +//! `inherits` template expansion and `$parent` name substitution; no widgets and no Lua. It +//! follows the reference's loader (`0x6edc00`–`0x6f3000`, the file loader `0x6ede10`), whose XML +//! tokenizer is an embedded expat and ours `roxmltree`. use std::collections::{HashMap, HashSet}; use std::fmt; -/// An owned XML element: tag, attributes (order preserved — Blizzard XML attribute order is -/// sometimes meaningful for authoring tools, and always useful for round-tripping/debugging), and -/// element children. Case-insensitive attribute lookup: "attrs via `GetAttribute 0x6f2cf0`" and -/// element-name compares are case-insensitive in the real loader (`0x64a4c0`), and Blizzard's own -/// shipped XML is inconsistent about attribute casing (e.g. both `relativeTo` and stray -/// all-lowercase variants appear in the wild), so lookups here fold case rather than assume a -/// canonical spelling. +/// An owned XML element, attributes in document order. Lookups fold case, as the reference's +/// `GetAttribute 0x6f2cf0` and element-name compares (`0x64a4c0`) do. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Element { pub tag: String, attrs: Vec<(String, String)>, pub children: Vec, - /// The node's own direct text content (the XML node struct's `body-text @ +0xc`). Populated for - /// text-bearing elements such as `` handler bodies (`lua…`) and - /// inline ``). Both run in document order relative to everything else at the top -/// level (`0x704bc0` / `0x704cd0`, from `0x6ede10`'s walk — this is how XML-referenced FrameXML Lua -/// loads). +/// A `` with no text at all. +/// The 1-based line of the first text byte inside a ` assert!(doc.warnings.is_empty()); } - /// `inherits="A, B"` is **one name**, not a list — and it misses. - /// - /// This test used to assert the opposite (that both templates merged, children concatenated in - /// order). That was a superset of 1.12 borrowed from later clients: there is no splitter - /// anywhere in the loader — all 8 call sites hand `0x6ee6f0` the pointer `GetAttribute` - /// returned, one or two instructions later, and no comma is examined in `0x6ed000–0x6f6000` at - /// all. So the lookup runs **once**, for the literal name `"A, B"`, misses, warns at - /// severity 1, and the element loads with **neither** template applied. - /// - /// Whitespace is never trimmed either, which is why the second half asserts `" A "` misses a - /// template that genuinely exists. + /// All eight callers of `0x6ee6f0` pass `GetAttribute`'s pointer as is, and nothing in + /// `0x6ed000`–`0x6f6000` examines a comma; the value is not trimmed either. #[test] fn a_comma_list_is_one_literal_name_that_misses_and_applies_neither_template() { let doc = parse( @@ -867,14 +658,13 @@ print(x) let mut warnings = Vec::new(); let expanded = expand(inst, &templates, &mut warnings); - // Warned, naming the whole unsplit string — not split, not silently ignored. assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!( warnings[0].contains("A, B"), "the warning must name the literal it looked up: {warnings:?}" ); - // The element still loads, with all of its OWN content and none of the templates'. + // The element loads with its own content only. assert_eq!(expanded.attr("alpha"), Some("1.0")); assert_eq!(expanded.children.len(), 1); fn texture_name(el: &Element) -> &str { @@ -882,7 +672,7 @@ print(x) } assert_eq!(texture_name(&expanded.children[0]), "FromInst"); - // And a padded name misses a template that exists — the value is used verbatim. + // A padded name misses: the value is used verbatim. let TopLevel::Instance(padded) = &doc.items[3] else { panic!("expected instance") }; @@ -916,8 +706,7 @@ print(x) let mut warnings = Vec::new(); let expanded = expand(leaf, &templates, &mut warnings); assert!(warnings.is_empty()); - // Mid's own frameStrata (it overrode Root's) wins, since chain-expanding Mid resolves Root - // first and Mid's own attrs on top, before Leaf (which sets nothing) merges on top of that. + // Mid's frameStrata overrides Root's, and Leaf sets none. assert_eq!(expanded.attr("frameStrata"), Some("MEDIUM")); assert_eq!( expanded.children.len(), @@ -941,7 +730,6 @@ print(x) panic!("expected instance") }; let mut warnings = Vec::new(); - // Must terminate (not infinitely recurse) and report the cycle. let _expanded = expand(inst, &templates, &mut warnings); assert!( warnings.iter().any(|w| w.contains("cycle")), @@ -964,14 +752,13 @@ print(x) resolve_name("NotAParentName", "PlayerFrame"), "NotAParentName" ); - // No named ancestor: caller passes the literal "Top" fallback. + // No named ancestor: the caller passes the literal "Top". assert_eq!(resolve_name("$parentFoo", DEFAULT_PARENT_NAME), "TopFoo"); } #[test] fn parent_name_substitution_composes_through_nested_children() { - // A named parent, a child using $parent, and a grandchild using $parent against the - // child's *resolved* name — not the child's raw literal "$parent..." text. + // The grandchild resolves against the child's resolved name, not its raw "$parent…" text. let parent_name = "PlayerFrame"; let child_name = resolve_name("$parentHealthBar", parent_name); assert_eq!(child_name, "PlayerFrameHealthBar"); diff --git a/crates/benilla-ui/src/justify.rs b/crates/benilla-ui/src/justify.rs index bce2e153..a2a2255c 100644 --- a/crates/benilla-ui/src/justify.rs +++ b/crates/benilla-ui/src/justify.rs @@ -1,56 +1,11 @@ -//! The justify enum — **one** transcription of `.rdata 0x811ad0`, shared by every table that -//! speaks it. -//! -//! ## Why this is a module and not three sets of `match` arms -//! -//! Justification is set on **three** widget tables — a `FontString` (`0x87c1d8`), a `` -//! object (`0x87c7c8`) and an `EditBox` — and each had been transcribed *independently* from the -//! same byte law. Each drifted its own way, and each looked right alone: -//! -//! | | FontString ([`crate::script::region`]) | Font ([`crate::script::font`]) | EditBox ([`crate::widget::kinds::editbox`]) | the bytes | -//! |---|---|---|---|---| -//! | `GetJustifyH`/`V` | **absent** | present | present | present (`0x79e5f0`/`0x79e7f0`) | -//! | space-padded token | rejected | `.trim()`ed, **accepted** | rejected | rejected (whole-string) | -//! | unmatched string | silently `CENTER` | silently `CENTER` | **raises** | **raises** | -//! | cross-axis token | silently `CENTER` | silently `CENTER` | **clears the axis** | clears the axis | -//! | axis storage | resolved enum → **the dword** (1239) | resolved enum | **the raw dword** | one dword | -//! -//! That is the seam class the addon-lane round trip named: halves transcribed separately from one -//! law drift where they meet, while every test on every side still passes. **The EditBox column is -//! the one that was right** — written last, straight from the bytes, and never consulted by the two -//! older tables. So this module is not new law: it is that column, lifted to where all three reach -//! it. It sits at the crate root rather than under `script` because `widget::kinds` needs it too. -//! -//! ## The law -//! -//! Storage is a single dword — `CSimpleFont+0x54`, `CSimpleFontString+0x120` — with **bits 0–2 -//! horizontal and bits 3–5 vertical**. `SetJustifyH 0x79fc20` computes `(cur ^ parsed) & 0x07 ^ -//! cur`, i.e. `(cur & !0x07) | (parsed & 0x07)`, replacing only its own axis (VERIFIED at -//! `0x79fc5d`–`0x79fc64`); `SetJustifyV 0x79fce0` uses mask `0x38`. The ctor default is `0x212` = -//! `CENTER | MIDDLE | 0x200`, bit 9 being outside both masks and read by neither accessor. -//! -//! Two consequences fall out of that arithmetic, and both are transcribed below: -//! -//! - **A cross-axis token is accepted and clears.** `SetJustifyH("TOP")` parses fine (`0x08`) but -//! `0x08 & 0x07 == 0`, so it erases the horizontal justification with **no error raised**, and a -//! following `GetJustifyH()` answers `"UNKNOWN"` — while the glyphs keep drawing **centred**. -//! The two readers genuinely disagree, which is why [`Justify`] stores the dword rather than a -//! resolved enum: [`Justify::name_h`] is the getter's answer and [`Justify::paint_h`] is the -//! draw path's, and they differ only in this state. -//! - **A non-token raises.** `0x6f1990` scans all six entries with `SStrCmpI` — whole-string, -//! case-insensitive, **no trimming** — and returns 0 on a miss, which is the caller's cue to -//! raise `Usage: %s:SetJustifyH("justify")` (`.rdata 0x87c77c`) / `…SetJustifyV…` -//! (`0x87c7a0`). Per `script::binding_abi`, that arm does not return: it abandons the caller's -//! statement. -//! -//! The setter also clears the inherit bit unconditionally, whether or not the axis value changed -//! (`0x79fc7c`). +//! The justify dword and its token table (`.rdata 0x811ad0`), shared by the `FontString` +//! (`0x87c1d8`), `Font` (`0x87c7c8`) and `EditBox` method tables. Each setter replaces only its +//! own axis, and also clears the inherit bit whether or not the value changed (`0x79fc7c`). use crate::script::{JustifyH, JustifyV}; -/// `.rdata 0x811ad0` — six `{u32 bits, const char* name}` entries, in image order. The order is -/// load-bearing twice over: `0x6f1990` scans it linearly, and `0x6f1a00` answers with the **first** -/// entry whose bit is set. +/// `.rdata 0x811ad0`, six `{u32 bits, const char* name}` entries in image order. The order +/// matters: `0x6f1990` scans linearly and `0x6f1a00` answers the first entry with its bit set. const TOKENS: [(u32, &str); 6] = [ (0x01, "LEFT"), (0x02, "CENTER"), @@ -63,43 +18,30 @@ const TOKENS: [(u32, &str); 6] = [ /// The literal `0x6f1a00` answers when no bit in the requested axis is set (`.data 0x838044`). pub const UNKNOWN: &str = "UNKNOWN"; -/// The horizontal axis — bits 0–2, `SetJustifyH`'s mask. +/// The horizontal axis, bits 0–2: the mask of `SetJustifyH 0x79fc20`. pub const H_MASK: u32 = 0x07; -/// The vertical axis — bits 3–5, `SetJustifyV`'s mask. +/// The vertical axis, bits 3–5: the mask of `SetJustifyV 0x79fce0`. pub const V_MASK: u32 = 0x38; -/// What a justify argument turned out to be — the three outcomes of `0x6f1990` followed by the -/// setter's mask. +/// A justify argument after `0x6f1990` and the setter's mask. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Set { - /// A token carrying a bit on *this* axis: the new justification. + /// A token with a bit on this axis. To(J), - /// A token that matched the table but carries no bit on this axis — `SetJustifyH("TOP")`. - /// The reference **clears** the axis and raises nothing; `GetJustifyH()` then answers - /// `"UNKNOWN"` while the glyphs keep drawing centred ([`Justify::paint_h`]). - /// - /// Not hypothetical: 13 corpus sites call `SetJustifyV("CENTER")` meaning "middle" - /// (`FonzAppraiser` ×12, `Roid-Macros`), which parses and then erases the vertical axis. + /// A token with no bit on this axis (`SetJustifyH("TOP")`): the reference clears the axis and + /// raises nothing; corpus addons call `SetJustifyV("CENTER")` and hit this. Clears, - /// No entry matched. The caller raises its own `Usage:` string; it does not coerce. + /// No entry matched: the caller raises its `Usage:` string, never coerces. NoMatch, } -/// The ctor default — `CENTER | MIDDLE | 0x200`. Bit 9 is outside both axis masks and is read by -/// neither accessor nor the gx translator. `CSimpleFontString`'s **own** ctor writes it at -/// `0x770dd3`, so a FontString that has never been told otherwise reads `CENTER`/`MIDDLE`. +/// `CENTER | MIDDLE | 0x200`, as `CSimpleFontString`'s ctor writes it (`0x770dd3`); bit 9 is in +/// neither axis and no reader looks at it. const CTOR_DEFAULT: u32 = 0x212; -/// The justify **dword** — `CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own — -/// with bits 0–2 horizontal and bits 3–5 vertical. -/// -/// Stored as the dword rather than a pair of resolved enums because **an axis with no bit set is a -/// real, reachable state** and a resolved enum cannot hold it: `SetJustifyH("TOP")` parses `0x08`, -/// contributes nothing to mask `0x07`, and erases the axis with no error raised. -/// -/// The two readers then disagree, faithfully, and that is the whole reason this type exists: -/// [`Justify::name_h`] (the Lua getter) answers `"UNKNOWN"`, while [`Justify::paint_h`] (the draw -/// path) answers `CENTER`. Anything that collapses the two loses one of them. +/// The justify dword (`CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own), +/// bits 0–2 horizontal and 3–5 vertical. Kept raw because an axis with no bit set is reachable, +/// and there the getter answers `"UNKNOWN"` while the draw path centres. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Justify(pub u32); @@ -110,11 +52,9 @@ impl Default for Justify { } impl Justify { - /// Replace the horizontal bits — `(cur & !0x07) | (parsed & 0x07)`. pub fn set_h(&mut self, j: JustifyH) { self.0 = set_axis(self.0, H_MASK, bits_h(j)); } - /// Replace the vertical bits — `(cur & !0x38) | (parsed & 0x38)`. pub fn set_v(&mut self, j: JustifyV) { self.0 = set_axis(self.0, V_MASK, bits_v(j)); } @@ -122,46 +62,36 @@ impl Justify { pub fn clear_h(&mut self) { self.0 = set_axis(self.0, H_MASK, 0); } - /// Erase the vertical axis. pub fn clear_v(&mut self) { self.0 = set_axis(self.0, V_MASK, 0); } - /// `GetJustifyH`'s answer — `0x6f1a00`, so `"UNKNOWN"` for a cleared axis. + /// `GetJustifyH`'s answer (`0x79e5f0`) through `0x6f1a00`: `"UNKNOWN"` for a cleared axis. pub fn name_h(self) -> &'static str { name_of(self.0, H_MASK) } - /// `GetJustifyV`'s answer. + /// `GetJustifyV`'s answer (`0x79e7f0`). pub fn name_v(self) -> &'static str { name_of(self.0, V_MASK) } - /// What the glyphs actually draw at horizontally — the ui→gx translator `0x44d420`, whose one - /// call site image-wide is `0x772693`. - /// - /// **It is a priority ladder, not a switch, and the fall-through is `CENTER` — not `LEFT`.** - /// Each axis's result register is pre-set to `1` *between* the `test` and the `jcc` - /// (`0x44d4fb mov ebx,0x1`, then `0x44d516 je`), so an all-clear axis exits with `1` still in - /// it, and `1` is bit-identical to CENTER. A cleared axis is therefore visually - /// indistinguishable from the ctor default. - /// - /// **The trap, quoted from the finding:** the gx layer's `else` arms genuinely *are* LEFT and - /// BOTTOM, but both are unreachable from a FontString — `0x44d420` emits only `{0,1,2}` and - /// `0x5c1c30` rejects `>= 3`. *Mapping the ui bitmask onto the gx enum with a `0` default - /// inverts both axes.* + /// Where the glyphs draw horizontally: the translator `0x44d420` (called only from + /// `0x772693`) is a ladder whose result is pre-set to `1`, CENTER (`0x44d4fb`, before the + /// `je` at `0x44d516`), so a cleared axis draws centred. The gx enum's `else` arms, LEFT and + /// BOTTOM, are unreachable (`0x44d420` emits only 0 to 2, `0x5c1c30` rejects 3 and up): + /// mapping the bitmask onto that enum with a `0` default inverts both axes. pub fn paint_h(self) -> JustifyH { if self.0 & 0x04 != 0 { JustifyH::Right } else if self.0 & 0x01 != 0 { JustifyH::Left } else { - JustifyH::Center // 0x02 set, or NOTHING set — the pre-set `1` + JustifyH::Center // 0x02 set, or no bit set: the pre-set `1` } } - /// What the glyphs draw at vertically — the same ladder, `TOP > BOTTOM > MIDDLE`, falling - /// through to `MIDDLE`. `0x44d536`'s redundant `mov esi,0x1` on the MIDDLE arm is MSVC's own - /// tell that `default:` and `MIDDLE` share a constant. + /// The same ladder vertically, `TOP > BOTTOM > MIDDLE`, falling through to MIDDLE + /// (`0x44d536`). pub fn paint_v(self) -> JustifyV { if self.0 & 0x08 != 0 { JustifyV::Top @@ -189,8 +119,7 @@ fn bits_v(j: JustifyV) -> u32 { } } -/// `0x6f1990` — a linear scan of all six entries with `SStrCmpI`: **whole-string, -/// case-insensitive, and without trimming**, so `"left "` is a miss. +/// `0x6f1990`: `SStrCmpI` over the six entries, whole-string and untrimmed, so `"left "` misses. pub fn parse_bits(s: &str) -> Option { TOKENS .iter() @@ -198,7 +127,7 @@ pub fn parse_bits(s: &str) -> Option { .map(|(bits, _)| *bits) } -/// `0x6f1a00` — the **first** entry whose bit is set within `mask`, else the literal `UNKNOWN`. +/// `0x6f1a00`: the first entry whose bit is set within `mask`, else the literal `UNKNOWN`. pub fn name_of(bits: u32, mask: u32) -> &'static str { TOKENS .iter() @@ -206,14 +135,13 @@ pub fn name_of(bits: u32, mask: u32) -> &'static str { .map_or(UNKNOWN, |(_, name)| *name) } -/// Replace one axis's bits with `parsed`'s, masked to that axis — `0x79fc5d`'s -/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`. This is the operation -/// that makes a cross-axis token *clear* rather than raise. +/// The setters' `(cur ^ parsed) & mask ^ cur` (`SetJustifyH` at `0x79fc5d`–`0x79fc64`), which is +/// why a cross-axis token clears rather than raises. pub fn set_axis(cur: u32, mask: u32, parsed: u32) -> u32 { (cur & !mask) | (parsed & mask) } -/// `SetJustifyH`'s argument, resolved against mask `0x07`. +/// `SetJustifyH`'s argument, against mask `0x07`. pub fn parse_h(s: &str) -> Set { match parse_bits(s) { None => Set::NoMatch, @@ -224,7 +152,7 @@ pub fn parse_h(s: &str) -> Set { } } -/// `SetJustifyV`'s argument, resolved against mask `0x38`. +/// `SetJustifyV`'s argument, against mask `0x38`. pub fn parse_v(s: &str) -> Set { match parse_bits(s) { None => Set::NoMatch, @@ -235,18 +163,17 @@ pub fn parse_v(s: &str) -> Set { } } -/// `GetJustifyH`'s answer for a resolved horizontal justification, through the same table. +/// `GetJustifyH`'s answer for a resolved justification. pub fn name_h(j: JustifyH) -> &'static str { name_of(bits_h(j), H_MASK) } -/// `GetJustifyV`'s answer for a resolved vertical justification, through the same table. pub fn name_v(j: JustifyV) -> &'static str { name_of(bits_v(j), V_MASK) } -/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`). The `%s` is spelled as -/// the widget type, matching the shipped convention in `script::font_block`. +/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`), the `%s` spelled as +/// the widget type. pub fn usage_h(widget: &str) -> mlua::Error { mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyH(\"justify\")")) } @@ -265,8 +192,7 @@ mod tests { assert_eq!(parse_h("LEFT"), Set::To(JustifyH::Left)); assert_eq!(parse_h("left"), Set::To(JustifyH::Left)); assert_eq!(parse_h("LeFt"), Set::To(JustifyH::Left)); - // `SStrCmpI` compares the whole string; the client does not trim, and neither do we — - // the Font-object path used to, which is the divergence this module exists to end. + // `SStrCmpI` compares the whole string, untrimmed. assert_eq!(parse_h("left "), Set::NoMatch); assert_eq!(parse_h(" left"), Set::NoMatch); assert_eq!(parse_h("leftmost"), Set::NoMatch); @@ -275,8 +201,6 @@ mod tests { #[test] fn an_unmatched_string_does_not_coerce_to_center() { - // The whole point of the raise: the reference tells the addon it passed nonsense, where we - // used to silently answer CENTER on both tables. assert_eq!(parse_h("MIDDLE_LEFT"), Set::NoMatch); assert_eq!(parse_v("CENTERED"), Set::NoMatch); } @@ -303,15 +227,12 @@ mod tests { #[test] fn the_formatter_is_first_bit_set_within_the_axis_else_unknown() { - // `0x6f1a00`'s two documented edges, neither reachable through our resolved enums but both - // part of the law being transcribed: no bit in the axis answers the literal UNKNOWN, and a - // dword with two bits set answers the *first* table entry rather than the largest. + // `0x6f1a00`: no bit in the axis answers UNKNOWN, two bits answer the first table entry. assert_eq!(name_of(0x00, H_MASK), "UNKNOWN"); assert_eq!(name_of(0x38, H_MASK), "UNKNOWN"); // vertical bits only assert_eq!(name_of(0x05, H_MASK), "LEFT"); // LEFT | RIGHT assert_eq!(name_of(0x30, V_MASK), "MIDDLE"); // MIDDLE | BOTTOM - // The ctor default `0x212` reads CENTER/MIDDLE, and bit 9 is invisible to both axes. - assert_eq!(name_of(0x212, H_MASK), "CENTER"); + assert_eq!(name_of(0x212, H_MASK), "CENTER"); // the ctor default; bit 9 is in no axis assert_eq!(name_of(0x212, V_MASK), "MIDDLE"); } } diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 8ec33152..8c68f058 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -1,66 +1,37 @@ -//! The frame anchor/layout resolver — anchor graph → resolved rects (decision 0068). +//! The frame anchor resolver: the 1.12 client's `CLayoutFrame` rect resolution, transcribed +//! function by function, with the reference's operations in the reference's order. //! -//! This is a faithful transcription of the WoW 1.12.1 client's `CLayoutFrame` geometry resolver, -//! reverse-engineered bit-exact against `WoW.exe`. The leaf float kernels and the -//! recursion-guarded per-edge composition are transcribed from the reference, function by function -//! and verified bit-exact. Matching the reference's operations and their order — not "improving" -//! the arithmetic — is the goal. +//! The client computes on the x87 at 53-bit precision, which `f64` matches, and narrows to `f32` +//! only at its stores (each sub-edge before a `combine_*`, each final edge, the `width * scale` +//! span), so the arithmetic here runs in `f64` with an explicit `as f32` at exactly those sites. //! -//! ## Precision — why `f64` intermediates, not naive `f32` -//! -//! The client is an x87 binary: every intermediate lives in an FPU register at PC_53 (53-bit -//! mantissa = IEEE `f64` precision) and is narrowed to `f32` *only* at the documented `fstp m32` -//! store sites (each sub-edge result before a `combine_*`; each final edge into the out-rect; the -//! `width·scale` span). A leaf like `(right + left) * 0.5 + xOff·scale` therefore rounds **once** -//! (at the final store), whereas doing every operation in `f32` would round at each step and -//! diverge. So "the client is f32" is true only at the storage boundaries: to be bit-faithful the -//! chain of `+`/`-`/`*0.5` runs in `f64` (host-exact — every operand is `f32`-derived, so each op is -//! exact in `f64`) with an explicit `as f32` at exactly the sites the reference pins. -//! -//! ## Coordinate space -//! -//! Rects are stored in the client's own convention: `[bottom, left, top, right]` (frame-absolute -//! `+0x64/+0x68/+0x6c/+0x70`; `GetRect 0x768320` and the Lua `GetBottom/Left/Top/Right` -//! readers). WoW UI space is **y-up**: `top > bottom`, the screen root's rect is the -//! physical screen (the anchor chain bottoms out at the `CSimpleTop` root), and `width = right − -//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale = -//! effective scale, `0x76ac90`) scales only the per-anchor offsets and the -//! `width/height` span. +//! Rects are `[bottom, left, top, right]` (frame `+0x64..+0x70`, `GetRect` `0x768320`) in screen +//! pixels, y-up. use std::collections::VecDeque; -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Constants (objdump-read from `WoW.exe`) -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Constants ──────────────────────────────────────────────────────────────────────────────── -/// UNSET coordinate sentinel: `+Infinity` (the `.data` slot `0xcf550c`, runtime-initialized from -/// `[0x81d56c] = 0x7f800000`). "coordinate is set" ⇔ `x != +Inf`. Intermediates carry -/// this in `f64`; the `f32` narrowing preserves it (`+Inf as f32 == +Inf`). +/// The unset-coordinate sentinel, `+Inf` (`0xcf550c`, set at runtime from `0x81d56c`). const UNSET: f64 = f64::INFINITY; -/// `OnSizeChanged` trigger ε — `_DAT_008029d4` = `0x34800000` ≈ 2.384e-7 (applied in -/// `ApplyRect 0x76b580`). `OnSizeChanged(width,height)` fires iff `|Δwidth| ≥ ε` OR -/// `|Δheight| ≥ ε` between the old and new resolved rect. See [`size_changed`]. +/// The least width or height change that fires `OnSizeChanged`: 2^-22, bits `0x34800000` at +/// `0x8029d4`, tested in `ApplyRect` (`0x76b580`). pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7; -/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (applied in -/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store + -/// `ApplyRect`. Benilla owns its own caching (the caller's dirty gate); this const records the client's -/// documented threshold for fidelity work that needs it. +/// The least edge move against the cached rect for the client to re-store it and `ApplyRect`, 1e-5 +/// (`0x80c5c8`, in `0x768d20`). Recorded, not applied: the caller's own dirty gate decides. pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64; -/// point-id → X column (table `0x81c3b8`..): -/// `{0,3,6}` (LEFT column) → 0, `{1,4,7}` (center) → 1, `{2,5,8}` (RIGHT column) → 2. +/// Point id to X column: left 0, center 1, right 2. const X_COL: [u8; 9] = [0, 1, 2, 0, 1, 2, 0, 1, 2]; -/// point-id → Y row: `{0,1,2}` (TOP row) → 0, `{3,4,5}` (center) → 1, `{6,7,8}` (BOTTOM row) → 2. +/// Point id to Y row: top 0, center 1, bottom 2. const Y_ROW: [u8; 9] = [0, 0, 0, 1, 1, 1, 2, 2, 2]; -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Public types -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Public types ───────────────────────────────────────────────────────────────────────────── -/// The nine anchor points, with the client's exact discriminants (static tables -/// `0x81c3b8–0x81c3fc`): the id is the slot index in the client's `anchorPoints[9]`. +/// The nine anchor points; the discriminant is the client's point id, the slot in its +/// `anchorPoints[9]` (tables at `0x81c3b8..0x81c3fc`). #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Point { @@ -76,13 +47,11 @@ pub enum Point { } impl Point { - /// The point-id (0..=8), which is also the `anchorPoints[9]` slot index. #[inline] pub const fn id(self) -> u8 { self as u8 } - /// Build a [`Point`] from its client id, `None` if out of range. pub const fn from_id(id: u8) -> Option { Some(match id { 0 => Point::TopLeft, @@ -99,11 +68,10 @@ impl Point { } } -/// An opaque handle to an anchor target. This crate does not own the frame arena; the caller maps -/// handles to frames / external rects. (In the client this is the `CAnchor+0xc relativeTo` pointer.) +/// An anchor target, which the caller maps to a frame or an external rect (`CAnchor+0xc`). pub type Handle = u32; -/// A resolved rect in the client's `[bottom, left, top, right]` convention (screen pixels, y-up). +/// A resolved rect, `[bottom, left, top, right]` in screen pixels, y-up. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rect { pub bottom: f32, @@ -113,7 +81,6 @@ pub struct Rect { } impl Rect { - /// Construct from the four edges in client order. pub const fn new(bottom: f32, left: f32, top: f32, right: f32) -> Rect { Rect { bottom, @@ -123,51 +90,39 @@ impl Rect { } } - /// `[bottom, left, top, right]` — the client's cached-rect layout (`+0x64..0x70`). #[inline] pub const fn to_array(self) -> [f32; 4] { [self.bottom, self.left, self.top, self.right] } - /// From `[bottom, left, top, right]`. #[inline] pub const fn from_array(a: [f32; 4]) -> Rect { Rect::new(a[0], a[1], a[2], a[3]) } - /// `right − left` (`GetWidth` semantics). #[inline] pub fn width(self) -> f32 { self.right - self.left } - /// `top − bottom` (`GetHeight` semantics; y-up). #[inline] pub fn height(self) -> f32 { self.top - self.bottom } } -/// One anchor of a frame — a `CAnchor` record -/// (`{xOff@4, yOff@8, relativeTo@0xc, relativePoint@0x10}`). `point` is the point *on this frame* -/// being pinned (its `anchorPoints[9]` slot); `relative_point` is the point *on the target* it -/// pins to. +/// One anchor, a client `CAnchor` (`xOff +4`, `yOff +8`, `relativeTo +0xc`, `relativePoint +0x10`): +/// `point` on this frame pins to `relative_point` on the target. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Anchor { - /// The point on *this* frame that is being anchored (its `anchorPoints[9]` slot index). pub point: Point, - /// The target frame/region this anchor is relative to (`CAnchor+0xc`). pub relative_to: Handle, - /// The point on the *target* this anchor pins to (`CAnchor+0x10 relativePoint`). pub relative_point: Point, - /// X offset (`CAnchor+4`), scaled by the requesting frame's `scale`. pub x_off: f32, - /// Y offset (`CAnchor+8`), scaled by `scale`. pub y_off: f32, } impl Anchor { - /// Convenience constructor. pub fn new( point: Point, relative_to: Handle, @@ -185,28 +140,24 @@ impl Anchor { } } -/// A frame's full layout input: its anchors, its optional explicit size, its scale, and the -/// clamp-to-screen flag + extents. Mirrors the client's geometry subobject fields consulted by the -/// resolver: `anchorPoints[9]` (`G+0x28`), `width` -/// (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`), flags bit4 = clamp (`G+0x3c`). +/// A frame's layout input, the geometry fields the client's resolver reads: `anchorPoints[9]` +/// (`G+0x4`), `width` (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`) and the clamp flag +/// (bit 4 of `G+0x3c`). #[derive(Clone, Debug, PartialEq)] pub struct LayoutInput { - /// The frame's anchors. Placed into the nine `anchorPoints` slots by their `point`; if two share - /// a `point`, the *last* wins (the client's `SetPoint 0x767c70` overwrites the slot). + /// Slotted by `point`, the later of two on one point winning (`SetPoint`, `0x767c70`). pub anchors: Vec, - /// Explicit width (`G+0x50`); **`0.0` ⇒ derive from opposing anchors** (the no-explicit-size - /// sentinel `DAT_007ffd74`). + /// `0.0` is the no-size sentinel (`0x7ffd74`): the axis derives from the anchors. pub width: f32, - /// Explicit height (`G+0x54`); `0.0` ⇒ derive from opposing anchors. pub height: f32, - /// `layoutScale` = effective scale (`parentScale · ownScale`, ε-gated; `0x76ac90`); the - /// px↔coord multiplier applied to offsets and the size span. Default `1.0`. + /// The effective scale, parent times own (`0x76ac90`), applied only to the anchor offsets and + /// the size span. pub scale: f32, - /// Clamp the assembled rect into `[0, extent]` per axis (`G` flags bit4; `assemble 0x767a20`). + /// Clamp the rect into `[0, extent]` on each axis. pub clamp: bool, - /// Screen X extent, only consulted when `clamp` (client `0x41ae60` return, default `0.8`). + /// The clamp's X extent; the client's default is `0.8` (`0x41ae60`). pub extent_x: f32, - /// Screen Y extent, only consulted when `clamp` (client `0x41ae70` return, default `0.6`). + /// The clamp's Y extent; the client's default is `0.6` (`0x41ae70`). pub extent_y: f32, } @@ -236,31 +187,18 @@ impl LayoutInput { } } -/// The outcome of resolving one frame's rect. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ResolveOutcome { - /// Every edge resolved → the cached rect `[bottom, left, top, right]` (`assemble` returned 1). Resolved(Rect), - /// At least one edge stayed `+Inf` — under-constrained (`assemble 0x767a20` returns 0, caller - /// sets flags bit3 "resolution-failed"). + /// An edge stayed `+Inf`: the client's `assemble` returns 0 and its caller sets flags bit 3. Unresolvable, } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Leaf float kernels — the owned-fidelity math. Each returns its coordinate in `f64` (the -// un-narrowed x87 `st0` observable); callers narrow to `f32` at the documented store sites. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Leaf kernels: each returns the un-narrowed `f64` ───────────────────────────────────────── -/// `combineEdge 0x767440(center, opp, span)`: resolve one edge from the opposite -/// edge, the axis center, and the signed `size·scale` span, in that fallback order. -/// -/// - opposite known and non-zero span → `opp + span` (edge = opposite ± size·scale); -/// - else center known: non-zero span → `span·0.5 + center` (edge = center ± half-size); -/// else opposite known → `(center − opp) + center` (= 2·center − opposite, the mirror); -/// - else → `+Inf` (unresolvable). -/// -/// The `+Inf`/`0.0` sentinels are compared with IEEE equality, matching the binary's `fcomp` -/// against the `.data` slots (a NaN operand is unordered ⇒ "not equal to the sentinel"). +/// `combineEdge` (`0x767440`): an edge from the opposite edge and the signed `size * scale` span, +/// else from the axis center. The sentinels compare by IEEE equality, as the client's `fcomp` +/// does, so a NaN never matches one. #[allow(clippy::float_cmp)] pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 { let (c, o, s) = (f64::from(center), f64::from(opp), f64::from(span)); @@ -278,12 +216,7 @@ pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 { UNSET } -/// `combineCenter 0x7672d0(lo, hi, span)`: resolve an axis center from the low edge, -/// the high edge, and the signed `size·scale` span. -/// -/// - low known: high known → `(lo+hi)·0.5`; else non-zero span → `span·0.5 + lo`; -/// - else high known and non-zero span → `hi − span·0.5`; -/// - else → `+Inf`. +/// `combineCenter` (`0x7672d0`): an axis center from the low and high edges and the signed span. #[allow(clippy::float_cmp)] pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 { let (l, h, s) = (f64::from(lo), f64::from(hi), f64::from(span)); @@ -301,16 +234,9 @@ pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 { UNSET } -/// `CAnchor::ResolveX 0x7a2f90(scale)`: resolve this anchor's X coordinate against its -/// `relativeTo`'s cached rect. -/// -/// `rect` = the target's cached rect `[bottom, left, top, right]` when `GetRect` succeeded (cache -/// valid), `None` when it failed ⇒ `+Inf`. `mirror` = the target's geometry-vtable `+0x24` -/// normalize predicate (RTL/local-origin): when set, X edges are translated so `left → 0`, -/// `right → right − left` (each stored back as `f32`, the binary's `fstp m32`). For an ordinary -/// frame the predicate returns 0, so `mirror` is `false` (`0x46ff60 = xor eax,eax; ret`). -/// `coord = xOff·scale + edge` for the relativePoint's column -/// (center = mid-X + xOff·scale). `rel_point > 8` ⇒ `+Inf`. +/// `CAnchor::ResolveX` (`0x7a2f90`): the anchor's X against its target's cached `rect`, which is +/// `None` when `GetRect` fails. `mirror` is the target's normalize predicate (vtable `+0x24`, 0 for +/// an ordinary frame at `0x46ff60`), which shifts the edges to start at 0, each stored as `f32`. pub fn anchor_resolve_x( scale: f32, x_off: f32, @@ -337,9 +263,7 @@ pub fn anchor_resolve_x( } } -/// `CAnchor::ResolveY 0x7a3070(scale)` — the Y twin of [`anchor_resolve_x`]. `mirror` -/// normalizes the Y edges (`bottom → 0`, `top → top − bottom`); `coord = yOff·scale + edge` for the -/// relativePoint's row (center = mid-Y + yOff·scale). `rect` = `[bottom, left, top, right]`. +/// `CAnchor::ResolveY` (`0x7a3070`), the Y counterpart of [`anchor_resolve_x`]. pub fn anchor_resolve_y( scale: f32, y_off: f32, @@ -366,21 +290,16 @@ pub fn anchor_resolve_y( } } -/// The assembled (and possibly clamped) rect + whether every edge resolved. +/// [`assemble_rect`]'s result: `ok` when every edge resolved. #[derive(Clone, Copy, Debug, PartialEq)] pub struct AssembleRect { pub ok: bool, - /// `[bottom, left, top, right]` (meaningful only when `ok`). + /// `[bottom, left, top, right]`, meaningful only when `ok`. pub rect: [f32; 4], } -/// `assemble 0x767a20`: given the four resolved edge coordinates `[bottom, left, top, -/// right]` (each `+Inf` ⇒ unresolvable), fail if any is `+Inf`; else optionally clamp into -/// `[0, extent]` per axis. -/// -/// Clamp (geom flags bit4): a low edge `< 0` shifts to 0 carrying the span to the high edge; a high -/// edge past the screen extent shifts back carrying to the low edge, high edge = extent. Each store -/// is narrowed to `f32`. +/// `assemble` (`0x767a20`): fails if any edge is `+Inf`, else with `clamp` (flags bit 4) shifts +/// the rect into `[0, extent]` on each axis, low edge first, keeping its size. #[allow(clippy::float_cmp)] pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) -> AssembleRect { let [mut bottom, mut left, mut top, mut right] = edges; @@ -417,9 +336,8 @@ pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) } } -/// `OnSizeChanged` fire test (`ApplyRect 0x76b580`): fires iff `|Δwidth| ≥ [`SIZE_EPS`]` -/// OR `|Δheight| ≥ SIZE_EPS`, with `width = right − left`, `height = top − bottom`. Deltas are `f64` -/// of the `f32` edges (the binary's `fsub` of the `fld`'d rect components). +/// `ApplyRect`'s `OnSizeChanged` test (`0x76b580`): width or height moved by at least +/// [`SIZE_EPS`], the deltas taken in `f64` from the `f32` edges. pub fn size_changed(old: Rect, new: Rect) -> bool { let dw = (f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left)); @@ -428,15 +346,9 @@ pub fn size_changed(old: Rect, new: Rect) -> bool { dw.abs() >= SIZE_EPS || dh.abs() >= SIZE_EPS } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The composed resolver — recursion-guarded per-edge sequencing over the leaves above, -// bit-exact over the binary's `assemble 0x767a20` → six per-edge resolvers → anchor scans → leaf -// kernels. Adds NO new arithmetic — only integer control flow + the documented `fstp m32` `f32` -// narrowing at each site. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── The composed resolver: `assemble` over six recursion-guarded edge resolvers ────────────── -/// A resolved anchor for the internal edge tree: like [`Anchor`] but with the target's rect already -/// looked up (`None` ⇒ target had no valid rect ⇒ `GetRect`-fail leg). +/// An [`Anchor`] with its target's rect looked up, `None` where `GetRect` would fail. #[derive(Clone, Copy)] struct AnchorRel { x_off: f32, @@ -446,8 +358,6 @@ struct AnchorRel { mirror: bool, } -/// Internal per-edge resolver input: the nine `anchorPoints` slots (by point-id) with rects -/// resolved, plus the size/scale/clamp fields. struct EdgeInput { anchors: [Option; 9], width: f32, @@ -458,7 +368,7 @@ struct EdgeInput { extent_y: f32, } -// recursion-guard bits (the binary's `G+0x28` bitfield). +// The recursion-guard bits of the client's `G+0x28`. const G_LEFT: u32 = 0x01; const G_TOP: u32 = 0x02; const G_RIGHT: u32 = 0x04; @@ -466,7 +376,7 @@ const G_BOTTOM: u32 = 0x08; const G_XC: u32 = 0x10; const G_YC: u32 = 0x20; -// per-edge point-id scan tables (`.rdata` 0x81c3b8..0x81c3f4). +// The point ids each edge scans (`.rdata` 0x81c3b8..0x81c3f4). const IDS_LEFT: [usize; 3] = [0, 3, 6]; const IDS_RIGHT: [usize; 3] = [2, 5, 8]; const IDS_TOP: [usize; 3] = [0, 1, 2]; @@ -474,8 +384,7 @@ const IDS_BOTTOM: [usize; 3] = [6, 7, 8]; const IDS_XC: [usize; 3] = [1, 4, 7]; const IDS_YC: [usize; 3] = [3, 4, 5]; -/// `anchorScanX 0x7671a0`: over the edge's three point-ids, the first present anchor whose -/// `ResolveX` is non-`+Inf` wins; else `+Inf`. +/// `anchorScanX` (`0x7671a0`): the first of the edge's anchors whose X resolves, else `+Inf`. #[allow(clippy::float_cmp)] fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { @@ -489,7 +398,7 @@ fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { UNSET } -/// `anchorScanY 0x7671f0` — the Y twin. +/// `anchorScanY` (`0x7671f0`). #[allow(clippy::float_cmp)] fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { @@ -503,18 +412,10 @@ fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { UNSET } -/// `width·scale` / `height·scale` — the per-edge resolvers' shared span step (`LEFT 0x7673d0` et -/// al.): operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, -/// negated for LEFT/BOTTOM. -/// -/// **`size` is not always the authored field, and this is the trap** (decision 1349): each -/// caller's `call [eax+0x1c]` is **virtual**. A plain frame lands on -/// `0x768420 fld [G+0x50]` — the flat read this signature assumes — but a `CSimpleTexture` lands on -/// `0x770720` and a `CSimpleFontString` on `0x772930`, both of which substitute a **content-derived** -/// extent when the authored value is exactly `0.0` (the texture's own texel size, one texel per -/// FrameXML unit; the string's measured text, floored at one unit). benilla feeds this the authored -/// number only, so a zero-authored *texture* axis yields a zero span here where the client yields a -/// real one. 1349 §4 carries the change and its scope; do not read this kernel as the whole law. +/// The edge resolvers' span, `size * scale` narrowed to `f32`, negated for LEFT and BOTTOM. The +/// client reads `size` through a virtual getter: a frame's is the authored field (`0x768420`), but +/// a texture (`0x770720`) and a font string (`0x772930`) replace an authored `0.0` with their +/// content's extent, so a region's [`LayoutInput`] must already carry that extent. fn size_span(size: f32, scale: f32, negate: bool) -> f32 { let span = (f64::from(size) * f64::from(scale)) as f32; if negate { @@ -524,7 +425,7 @@ fn size_span(size: f32, scale: f32, negate: bool) -> f32 { } } -/// `LEFT 0x7673d0` — direct anchor scan, else `combineEdge(Xcenter, RIGHT, −width·scale)`. +/// LEFT (`0x7673d0`): its anchor scan, else `combineEdge(Xcenter, RIGHT, -width * scale)`. #[allow(clippy::float_cmp)] fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_LEFT != 0 { @@ -544,7 +445,7 @@ fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `RIGHT 0x767540` — direct anchor scan, else `combineEdge(Xcenter, LEFT, +width·scale)`. +/// RIGHT (`0x767540`): its anchor scan, else `combineEdge(Xcenter, LEFT, width * scale)`. #[allow(clippy::float_cmp)] fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_RIGHT != 0 { @@ -564,7 +465,7 @@ fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `TOP 0x7674d0` — direct anchor scan, else `combineEdge(Ycenter, BOTTOM, +height·scale)`. +/// TOP (`0x7674d0`): its anchor scan, else `combineEdge(Ycenter, BOTTOM, height * scale)`. #[allow(clippy::float_cmp)] fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_TOP != 0 { @@ -584,7 +485,7 @@ fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `BOTTOM 0x7675b0` — direct anchor scan, else `combineEdge(Ycenter, TOP, −height·scale)`. +/// BOTTOM (`0x7675b0`): its anchor scan, else `combineEdge(Ycenter, TOP, -height * scale)`. #[allow(clippy::float_cmp)] fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_BOTTOM != 0 { @@ -604,7 +505,7 @@ fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `Xcenter 0x767260` — direct anchor scan, else `combineCenter(LEFT, RIGHT, +width·scale)`. +/// Xcenter (`0x767260`): its anchor scan, else `combineCenter(LEFT, RIGHT, width * scale)`. #[allow(clippy::float_cmp)] fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_XC != 0 { @@ -624,7 +525,7 @@ fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `Ycenter 0x767360` — direct anchor scan, else `combineCenter(BOTTOM, TOP, +height·scale)`. +/// Ycenter (`0x767360`): its anchor scan, else `combineCenter(BOTTOM, TOP, height * scale)`. #[allow(clippy::float_cmp)] fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 { if *guard & G_YC != 0 { @@ -644,12 +545,8 @@ fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 { r } -/// `assemble 0x767a20` composed: resolve all four edges (each `fst`'d to the `f32` out-rect), -/// returning [`ResolveOutcome::Unresolvable`] if any stayed `+Inf`, else the assembled (and, when -/// `clamp`, screen-clamped) rect. The four top-level resolves share a fresh guard; the binary -/// clears each edge's bit on return, so order is immaterial — we keep the binary's LEFT→BOTTOM→ -/// RIGHT→TOP order. The `+Inf` test is on the un-narrowed `f64` edge (the binary's `fcomp` before -/// the `fst`). +/// `assemble` (`0x767a20`) composed, resolving LEFT, BOTTOM, RIGHT, TOP in the client's order; the +/// `+Inf` test reads the un-narrowed `f64` edge, as the client's `fcomp` precedes its `fst`. #[allow(clippy::float_cmp)] fn resolve_edges(f: &EdgeInput) -> ResolveOutcome { let mut guard = 0u32; @@ -665,13 +562,8 @@ fn resolve_edges(f: &EdgeInput) -> ResolveOutcome { ResolveOutcome::Resolved(Rect::from_array(a.rect)) } -/// Resolve one frame's rect. `target_rect` supplies each anchor target's already-resolved rect -/// (`None` ⇒ the target has no valid rect ⇒ that anchor contributes `+Inf`, the `GetRect`-fail -/// leg). Anchors are placed into the nine `anchorPoints` slots by their `point`; on a duplicate the -/// later anchor wins (the client's `SetPoint` overwrite). -/// -/// This is the fidelity surface: it builds the client's `anchorPoints[9]` view and runs the -/// composed `assemble 0x767a20`. +/// Resolve one frame's rect; `target_rect` gives each anchor target's resolved rect, and `None` +/// makes that anchor `+Inf`, as a failed `GetRect` does. pub fn resolve_rect( frame: &LayoutInput, target_rect: impl FnMut(Handle) -> Option, @@ -679,9 +571,6 @@ pub fn resolve_rect( resolve_edges(&build_edge_input(frame, target_rect)) } -/// Build the client's `anchorPoints[9]` view + size/scale/clamp fields for `frame`, resolving each -/// anchor target's rect through `target_rect`. Shared by [`resolve_rect`] (all-or-nothing) and -/// [`resolve_rect_edges`] (per-edge). fn build_edge_input( frame: &LayoutInput, mut target_rect: impl FnMut(Handle) -> Option, @@ -693,8 +582,7 @@ fn build_edge_input( y_off: a.y_off, rel_point: a.relative_point.id() as u32, rel_rect: target_rect(a.relative_to).map(Rect::to_array), - // Ordinary frames' normalize predicate returns 0 (`0x46ff60`); RTL/mirror - // frames are not modeled here (the leaf `anchor_resolve_*` covers that leg). + // An ordinary frame's normalize predicate returns 0 (`0x46ff60`). mirror: false, }); } @@ -709,12 +597,8 @@ fn build_edge_input( } } -/// Resolve one frame's four edges, returning each as `Some(edge)` when the anchor graph + explicit -/// size determined it, or `None` when it stayed `+Inf` (unset). The per-edge view **regions** need: -/// a region inherits any edge its anchors + size don't pin from its owner frame (decision 0068's v1 -/// region model — see [`crate::script`]'s region resolution), so it must see *which* edges resolved, -/// not the all-or-nothing verdict [`resolve_rect`] gives. Order matches [`Rect`]: `[bottom, left, -/// top, right]`. No `assemble`/clamp is applied (regions never clamp to screen in v1). +/// Resolve one frame's edges, `[bottom, left, top, right]`, each `None` where it stayed unset; +/// `assemble` and its clamp are not applied. The region pass reads it axis by axis. #[allow(clippy::float_cmp)] pub fn resolve_rect_edges( frame: &LayoutInput, @@ -730,55 +614,27 @@ pub fn resolve_rect_edges( [cvt(bottom), cvt(left), cvt(top), cvt(right)] } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// Graph driver — resolve a set of frames in dependency order (anchor targets before dependents), -// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves -// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (`0x7680e0`, ordered -// by `0x7681b0`); we express the same dependency ordering as an explicit topological pass. -// -// ## Why a dense, reusable solver rather than a fresh map-backed graph -// -// The caller re-solves the whole widget set every round of its fixpoint, and the fixpoint runs -// every frame. A map-backed graph rebuilt per round paid, per round: one `LayoutInput` clone -// (heap `Vec`) per frame, a `BTreeMap` insert per node, and a `BTreeMap` probe per anchor -// target during the solve. Measured on the shipped default UI (1881 frames + 5478 regions, -// release): 1.36 ms per resolve, of which ~0.25 ms was graph construction and ~0.64 ms the solve -// itself — paid on a completely quiet frame. -// -// [`Handle`]s are the caller's own dense id space (benilla's `Model::next_id`, monotonic from 1 -// with frames and regions sharing the counter), so every per-node map collapses to an array index -// and every buffer can live across calls. `begin` clears only the slots the previous round -// touched, so a round costs O(nodes), never O(id space). Iteration order is kept **ascending by -// handle** — identical to the `BTreeMap` it replaces — because cycle members are resolved -// best-effort in that order and the result depends on it. -// ───────────────────────────────────────────────────────────────────────────────────────────── +// ── Graph driver ───────────────────────────────────────────────────────────────────────────── -/// A reusable dependency solver over a set of anchored frames plus *external* pre-resolved rects -/// (the screen root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed -/// targets, such as already-resolved regions). -/// -/// One solver is kept alive across frames so its buffers are reused. A round is: -/// [`begin`](Self::begin) → [`set_external`](Self::set_external) / -/// [`set_frame`](Self::set_frame) → [`solve`](Self::solve) → [`rect`](Self::rect). +/// Resolves anchored frames in dependency order, a driver of benilla's own: the client resolves +/// lazily through a dependency-ordered deferred queue (`0x7680e0`, ordered by `0x7681b0`). Handles +/// index its arrays directly, so they must be dense. A round: [`begin`](Self::begin), +/// [`set_external`](Self::set_external) and [`set_frame`](Self::set_frame), [`solve`](Self::solve). #[derive(Clone, Debug, Default)] pub struct LayoutSolver { - /// Per-handle layout input. Persistent storage: [`set_frame`](Self::set_frame) overwrites in - /// place so the anchors `Vec` keeps its allocation across rounds. + /// Overwritten in place, so each anchors `Vec` keeps its allocation. input: Vec, - /// Is `input[h]` a frame to solve this round? is_frame: Vec, - /// Per-handle rect: externals as seeded, frames as they resolve. `None` = not (yet) known. + /// Externals as seeded, frames as they resolve. rect: Vec>, - /// Kahn in-degree, and the reverse edges (each inner `Vec` is cleared, never freed). + /// Kahn in-degree and reverse edges; each inner `Vec` is cleared, never freed. indeg: Vec, dependents: Vec>, - /// Handles whose slots were written this round — `begin` resets exactly these. + /// The handles written this round, the ones `begin` resets. touched: Vec, - /// Is `h` already in `touched`? marked: Vec, - /// The frames to solve, ascending (the `BTreeMap` iteration order this replaces). + /// Ascending: cycle members resolve in this order, and the result depends on it. live: Vec, - /// Kahn scratch + output. queue: VecDeque, emitted: Vec, order: Vec, @@ -787,12 +643,10 @@ pub struct LayoutSolver { } impl LayoutSolver { - /// A new, empty solver. pub fn new() -> LayoutSolver { LayoutSolver::default() } - /// Grow the per-handle arrays to cover `h`. fn ensure(&mut self, h: Handle) { let need = h as usize + 1; if self.input.len() < need { @@ -806,7 +660,7 @@ impl LayoutSolver { } } - /// Mark `h` as written this round (so [`begin`](Self::begin) will reset its slot). + /// Record `h` for [`begin`](Self::begin) to reset. fn touch(&mut self, h: Handle) { let i = h as usize; if !self.marked[i] { @@ -815,7 +669,7 @@ impl LayoutSolver { } } - /// Start a round: reset every slot the previous round touched (O(nodes), not O(id space)). + /// Start a round, resetting only the slots the last round touched. pub fn begin(&mut self) { for &h in &self.touched { let i = h as usize; @@ -834,17 +688,15 @@ impl LayoutSolver { self.unresolvable.clear(); } - /// Seed a pre-resolved rect at `h` — an anchor target this solve does not compute (the screen - /// root, an already-resolved region). Also used to publish a rect mid-sweep so later nodes in - /// the same pass see it. + /// Seed a rect this solve does not compute, such as the screen root (`CSimpleTop`); also + /// publishes one mid-sweep for later nodes of the same pass. pub fn set_external(&mut self, h: Handle, rect: Rect) { self.ensure(h); self.touch(h); self.rect[h as usize] = Some(rect); } - /// Register a frame to solve, copying `src` into the solver's persistent slot (the anchors - /// `Vec` is refilled in place, so a steady round allocates nothing). + /// Register a frame to solve, copying `src` into its slot; a steady round allocates nothing. pub fn set_frame(&mut self, h: Handle, src: &LayoutInput) { self.ensure(h); self.touch(h); @@ -856,9 +708,7 @@ impl LayoutSolver { self.live.push(h); } - /// [`set_frame`](Self::set_frame) with `src`'s anchors replaced by exactly `anchor` — the - /// ScrollFrame child override (decision 0112), which otherwise cost a full input clone plus a - /// one-element `Vec` per scroll child per round. + /// [`set_frame`](Self::set_frame) with `anchor` as the only anchor: a ScrollFrame child's. pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) { self.ensure(h); self.touch(h); @@ -870,7 +720,6 @@ impl LayoutSolver { self.live.push(h); } - /// The non-anchor fields, copied verbatim. fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) { dst.width = src.width; dst.height = src.height; @@ -880,18 +729,13 @@ impl LayoutSolver { dst.extent_y = src.extent_y; } - /// Resolve every registered frame in dependency order (each anchor target before its - /// dependents), detecting cycles. Cycle members are still resolved best-effort, ascending by - /// handle, against whatever rects were available — matching the map-backed driver this - /// replaces. + /// Resolve every registered frame, anchor targets first; cycle members then resolve + /// best-effort, ascending by handle, against the rects known by then. pub fn solve(&mut self) { self.live.sort_unstable(); - // Frame→frame anchor edges. A dependency on an external or an unknown handle does not - // constrain ordering (its rect is fixed / missing, not produced by this pass); only - // frame→frame edges do. A self-anchor counts as an edge, so a self-referential frame is - // never emitted ⇒ reported as a cycle. Duplicate targets count once (an anchor slot set is - // at most 9, so the dedupe is a linear scan, not a set). + // Only frame-to-frame anchors order the pass. A self-anchor counts, so a self-anchored + // frame lands in `cycle`; a target named twice counts once. for idx in 0..self.live.len() { let h = self.live[idx]; let mut seen: [Handle; 9] = [0; 9]; @@ -936,7 +780,7 @@ impl LayoutSolver { self.resolve_one(h); } - // Frames never emitted are on (or behind) a cycle; resolve them best-effort, ascending. + // A frame never emitted is on or behind a cycle. for idx in 0..self.live.len() { let h = self.live[idx]; if !self.emitted[h as usize] { @@ -946,7 +790,6 @@ impl LayoutSolver { } } - /// Resolve one frame against the rects known so far, recording failure. fn resolve_one(&mut self, h: Handle) { let outcome = { let input = &self.input[h as usize]; @@ -959,20 +802,18 @@ impl LayoutSolver { } } - /// The rect known at `h`: an external as seeded, or a frame's resolved rect. `None` if the - /// handle is unknown or its frame was under-constrained. + /// The rect at `h`: an external as seeded, a frame as resolved. #[inline] pub fn rect(&self, h: Handle) -> Option { self.rect.get(h as usize).copied().flatten() } - /// Frames on (or behind) an anchor dependency cycle — no valid topological position. Ascending. + /// Frames on or behind an anchor cycle, ascending. pub fn cycle(&self) -> &[Handle] { &self.cycle } - /// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an - /// unresolvable/missing target). May overlap [`cycle`](Self::cycle). + /// Frames that did not resolve; may overlap [`cycle`](Self::cycle). pub fn unresolvable(&self) -> &[Handle] { &self.unresolvable } @@ -982,10 +823,8 @@ impl LayoutSolver { mod tests { use super::*; - // Screen-root handle used across tests; its rect is the physical screen (y-up). const SCREEN: Handle = 0; fn screen_rect() -> Rect { - // [bottom, left, top, right] Rect::new(0.0, 0.0, 600.0, 800.0) } @@ -1007,12 +846,10 @@ mod tests { } // ── Leaf kernels ───────────────────────────────────────────────────────────────────────── - // Expected outputs are hand-computed from the formulas above — this crate has no `WoW.exe` - // emulator, so these are spec-derived, not binary-diffed. + // Expected values are hand-computed from the formulas, not taken from the client. #[test] fn combine_edge_legs() { - // (center, opp, span) assert_eq!(combine_edge(10.0, 50.0, 7.0), 57.0); // opp+span assert_eq!(combine_edge(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+center assert_eq!(combine_edge(10.0, 50.0, 0.0), (10.0 - 50.0) + 10.0); // 2c-opp = -30 @@ -1043,14 +880,13 @@ mod tests { #[test] fn anchor_resolve_x_columns() { let r = [3.0f32, 100.0, 480.0, 260.0]; // [bottom, left, top, right] - // relLeft, center, relRight (point-ids 0,1,2), scale 1, xOff 5 + // point ids 0, 1, 2 (left, center, right), xOff 5 assert_eq!(anchor_resolve_x(1.0, 5.0, 0, Some(r), false), 5.0 + 100.0); assert_eq!( anchor_resolve_x(1.0, 5.0, 1, Some(r), false), (260.0 + 100.0) * 0.5 + 5.0 ); assert_eq!(anchor_resolve_x(1.0, 5.0, 2, Some(r), false), 5.0 + 260.0); - // scale multiplies the offset assert_eq!( anchor_resolve_x(2.0, 5.0, 0, Some(r), false), 2.0 * 5.0 + 100.0 @@ -1112,7 +948,7 @@ mod tests { // ── Composed resolver ───────────────────────────────────────────────────────────────────── - /// A numeric oracle for `assemble 0x767a20`, confirmed against a running `WoW.exe`. + /// The rect the running reference client's `assemble` (`0x767a20`) produced. #[test] fn oracle_single_topleft_explicit_size() { let f = LayoutInput::sized( @@ -1126,16 +962,14 @@ mod tests { 200.0, 50.0, ); - // screen root [0,0,600,800] (probe uses root top=600, right=800) + // The reference run's screen root: top 600, right 800. let got = resolve_rect(&f, |_| Some(Rect::new(0.0, 0.0, 600.0, 800.0))); assert_rect(got, Rect::new(545.0, 10.0, 595.0, 210.0)); } #[test] fn single_anchor_all_nine_points_explicit_size() { - // For each own-point p, anchor p -> the same point on screen, xOff/yOff 0, w100 h40. - // The anchored corner/edge pins directly; the rest derive from size. We assert the pinned - // coordinate and the resulting width/height, computed by hand from the formulas above. + // Each point pinned to the same point on the screen: every leg must keep the 100x40 size. let screen = screen_rect(); for &p in &[ Point::TopLeft, @@ -1154,7 +988,6 @@ mod tests { ResolveOutcome::Resolved(r) => r, ResolveOutcome::Unresolvable => panic!("{p:?} unresolvable"), }; - // width/height always preserved assert_eq!(r.width().to_bits(), 100.0f32.to_bits(), "{p:?} width"); assert_eq!(r.height().to_bits(), 40.0f32.to_bits(), "{p:?} height"); } @@ -1190,7 +1023,6 @@ mod tests { #[test] fn scale_interaction() { - // scale 2 -> offsets and size span doubled. let f = LayoutInput { anchors: vec![Anchor::new( Point::TopLeft, @@ -1266,8 +1098,7 @@ mod tests { #[test] fn unset_sentinel_propagates() { - // Anchor to a target with no valid rect (GetRect fail) -> that edge is +Inf -> frame - // unresolvable (single anchor, so opposite edges can't derive). + // The one anchor's target has no rect, so its edges are +Inf and nothing else pins them. let f = LayoutInput::sized( vec![Anchor::new( Point::TopLeft, @@ -1330,7 +1161,6 @@ mod tests { s.solve(); assert!(s.cycle().contains(&A)); assert!(s.cycle().contains(&B)); - // neither can resolve (each depends on the other's rect, unavailable) assert!(s.unresolvable().contains(&A)); assert!(s.unresolvable().contains(&B)); } @@ -1355,18 +1185,15 @@ mod tests { #[test] fn size_changed_epsilon() { let r = Rect::new(0.0, 0.0, 40.0, 100.0); - // sub-epsilon width change -> not fired + // 1e-7 is under SIZE_EPS. let tiny = Rect::new(0.0, 0.0, 40.0, 100.0 + 1e-7); assert!(!size_changed(r, tiny)); - // supra-epsilon -> fired let big = Rect::new(0.0, 0.0, 40.0, 100.001); assert!(size_changed(r, big)); } - /// The solver's buffers live across rounds, so `begin` must leave NO trace of the previous - /// one — a stale rect, in-degree, dependent edge, or `is_frame` flag would silently corrupt - /// the next solve. Round 2 registers a *disjoint* frame set that anchors to a handle round 1 - /// used, and must see it as unknown (not round 1's rect). + /// The buffers live across rounds, so `begin` must leave nothing behind: round 2 anchors to a + /// handle only round 1 registered and must find it unknown. #[test] fn begin_leaves_no_state_from_the_previous_round() { const A: Handle = 1; @@ -1402,8 +1229,7 @@ mod tests { let a_first = s.rect(A).expect("A resolved in round 1"); assert!(s.rect(B).is_some()); - // Round 2: ONLY B, still anchored to A — but A is no longer registered and no external - // seeds it, so B must be unresolvable rather than reusing round 1's stale A rect. + // Round 2: only B, whose target A is no longer registered. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame( @@ -1426,8 +1252,7 @@ mod tests { assert!(s.unresolvable().contains(&B)); assert!(s.cycle().is_empty(), "cycle list must be cleared too"); - // Round 3: the original pair again — identical results to round 1 (no accumulated - // in-degree or dependent edges from the rounds between). + // Round 3: the original pair again, which must match round 1. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame( @@ -1460,9 +1285,8 @@ mod tests { assert!(s.unresolvable().is_empty()); } - /// A frame registered with the ScrollFrame override keeps its size/scale but drops its own - /// anchors for exactly the override (decision 0112) — and the slot's anchor `Vec` is reused, - /// so the previous round's anchors must not leak through. + /// The override keeps the size and scale but replaces the anchors, and the reused anchors `Vec` + /// must not leak the last round's. #[test] fn set_frame_anchored_replaces_only_the_anchors() { const A: Handle = 1; @@ -1486,8 +1310,7 @@ mod tests { bits(Rect::new(0.0, 0.0, 600.0, 800.0)) ); - // Overridden: the two authored anchors are gone, replaced by one TOPLEFT pin at (10, -5), - // so the explicit 100x40 size now decides the other two edges. + // Overridden: one TOPLEFT pin at (10, -5), so the 100x40 size decides the other edges. s.begin(); s.set_external(SCREEN, screen_rect()); s.set_frame_anchored( diff --git a/crates/benilla-ui/src/lib.rs b/crates/benilla-ui/src/lib.rs index 0bb5bbbc..4af31f01 100644 --- a/crates/benilla-ui/src/lib.rs +++ b/crates/benilla-ui/src/lib.rs @@ -1,25 +1,5 @@ -//! The engine-free core of benilla's UI engine (decision 0068): the WoW interface is data + a -//! runtime — TOC manifests, FrameXML documents, and Lua — and this crate owns everything about -//! that runtime which needs neither Bevy nor a GPU: parsing, template expansion, the anchor/layout -//! resolver, and the draw-order key. The Bevy side (rendering, input, the game-state API bindings) -//! lives in the app; the Lua VM is embedded by the app and calls back into this crate's model. -//! -//! Built currently: [`toc`] — the addon/FrameXML manifest parser; [`framexml`] — the FrameXML -//! document parser (order-preserving tree, template expansion, `$parent` substitution); -//! [`bindings_xml`] — the per-addon `Bindings.xml` parser (the third file format, feeding -//! [`script::keybind`]'s table); [`layout`] -//! — the anchor/layout resolver (anchor graph → resolved rects), transcribed bit-faithfully from -//! the reference; [`widget`] — the frame arena and its show/hide/strata/level/ -//! scale/alpha propagation mutations; [`order`] — the strata/draw-layer vocabulary, the packed -//! total-order key, and the visible-tree traversal that realizes the client's painter order; -//! [`script`] — the engine-free Lua host (mlua 5.1) binding the arena/layout/order model to the -//! FrameScript object model + WoW stdlib so FrameXML/addon Lua runs against it (decision 0068); -//! [`loader`] — the FrameXML loader that joins the two, walking a parsed document to materialize live -//! frames in a running [`script::UiScript`] by driving the object model exactly as an addon does; -//! [`markup`] — the single owner of WoW's inline-markup grammar (`|cAARRGGBB`, `|H…|h…|h`, `|n`, -//! `||`): the client's token decoder and the per-byte class map its cursor law is built on; -//! [`source`] — what a UI source file *is* before any parser sees it: bytes, BOM-stripped, decoded -//! to text only where a Rust parser forces it (decision 1193). +//! The engine-free core of benilla's UI: the `.toc`, FrameXML and `Bindings.xml` parsers, the +//! layout resolver, the frame arena, draw order and the Lua host FrameXML runs in; no Bevy, no GPU. pub mod bindings_xml; pub mod civil; diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs index 673df8e9..9b22381a 100644 --- a/crates/benilla-ui/src/loader/backdrop.rs +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -5,11 +5,9 @@ use crate::framexml::Element; use super::{abs_value, children_named, color_of, Loader}; impl Loader<'_> { - /// `` + children ``/``/``/ - /// ``/`` (LoadXML `0x77e6c0`). Builds the same table Lua `SetBackdrop` - /// reads and installs it, then applies the two colors — the XML `` parser (`0x6f23d0`) - /// defaults a *present* element's missing channels to **black** (r=g=b=0, a=1 via - /// [`color_of`]), distinct from the ctor white a table `SetBackdrop` leaves. + /// `` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two colours. + /// A present `` defaults a missing channel to black, alpha 1 (`0x6f23d0`), not the + /// white a table `SetBackdrop` leaves. pub(super) fn apply_backdrop(&mut self, el: &Element, wrapper: &Table, dbg: &str) { let Some(bd) = children_named(el, "Backdrop").next() else { return; @@ -32,10 +30,8 @@ impl Loader<'_> { } } - /// Build the `SetBackdrop` table from a `` element: `bgFile`/`edgeFile`/`tile` attrs, - /// ``/`` scalars ([`abs_value`]), and `` (an `` - /// child or inline `left`/`right`/`top`/`bottom` attrs). Missing keys are simply absent — the - /// object-model `SetBackdrop` leaves the ctor default (tileSize 0, edgeSize 32, insets 0). + /// The `SetBackdrop` table for a ``. A key the element omits stays absent, so + /// `SetBackdrop` keeps the ctor's default (tileSize 0, edgeSize 32, insets 0). pub(super) fn build_backdrop_table(&self, bd: &Element) -> mlua::Result { let t = self.lua().create_table()?; if let Some(f) = bd.attr("bgFile") { diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index d7aa26af..84d3a756 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -6,33 +6,15 @@ use super::regions::FontAttrs; use super::{abs_dim, children_named, Loader}; impl Loader<'_> { - /// LoadXML attribute handling (`0x769820`): the subset the v1 object model exposes, plus a - /// warn-once for the attributes whose methods don't exist yet (a next-phase gap, not a workaround). + /// A frame element's LoadXML attributes and simple children (`0x769820`), and a model pane's + /// own (`0x76cac0`). pub(super) fn apply_attrs(&mut self, el: &Element, wrapper: &Table, dbg: &str) { if el.attr_bool("hidden") { self.call(wrapper, "Hide", (), dbg); } - // **An unrecognised `frameStrata=` WARNS and skips; it does not raise** (decision 2160). - // - // The two doors are genuinely different in the reference and this is the one that is - // quiet. `CSimpleFrame::LoadXML 0x769820` resolves the name through `0x6f17d0`, whose miss - // arm returns `eax = 0` without writing the caller's out-parameter (`0x6f17ff`); the miss - // leg then pushes `0x878658 "Frame %s: Unknown frame strata: %s"` at severity 1 into the - // document's `CStatus` sink (`0x7699a4 call [edx+0xc]`, which returns — all five `Add` - // implementations image-wide are raise-free) and **reconverges with the hit path at - // `0x7699ad`**, carrying straight on into `frameLevel` and the rest of the subtree. - // `SetFrameStrata 0x76a470` is called only on the hit arm (`0x769971`), so the frame keeps - // whatever stratum it already had — its template's, its parent's, or the ctor's MEDIUM - // (`[+0xc0] = 3` at `0x7690c2`). - // - // The **Lua** binding is the loud one and stays as it is: `0x774360`'s miss reaches - // `0x774456 call 0x6f4940` (`luaL_error`), whose callee chain `luaG_errormsg 0x6fc780` / - // `luaD_throw 0x6f5d80` contains no `ret` at all — the epilogue after it is dead code. - // - // Resolving here rather than letting `SetFrameStrata` raise is what reproduces that split: - // routed through `self.call`, a bad value became a `report.errors` row, which is a script - // error the player sees — and it cost `EQL3` its whole `EQL3_Log.xml`, on a `` (a draw-LAYER name) the real client shrugs at. + // An unknown `frameStrata=` is logged (`0x7699a4`) and skipped, not raised as the Lua + // binding would (`0x774360`): `SetFrameStrata` runs only on a hit (`0x769971`), so the + // frame keeps the strata it had. if let Some(strata) = el.attr("frameStrata") { if crate::script::object::strata_from_str(strata).is_some() { self.call(wrapper, "SetFrameStrata", strata.to_string(), dbg); @@ -52,20 +34,8 @@ impl Loader<'_> { self.call(wrapper, "SetAlpha", a, dbg); } } - // `scale=` on a MODEL pane is the model's own scale, not the frame's: `CSimpleModel:: - // LoadXML` (`0x76cac0`) writes it into `+0x3a0` at `76cb61` — the field `SetModelScale` - // writes — and raises `Frame %s: Invalid model scale: %s` at `76cb92` for `≤ 0` (a raise, - // not a clamp). Every `scale=` in the shipped - // FrameXML sits on a model pane (the cooldown indicator's 0.75, the autocast shine's - // 1.2/1.22, the pings' 0.4, the dressing room's 2.0), and until decision 2007 the loader - // read none of them. Whether the generic frame loader (`0x769820`) reads a `scale` - // attribute of its own is unconfirmed; a plain frame's `scale=` is left as it was. - // `file=` on a model pane is `SetModel` (`CSimpleModel::LoadXML` `0x76cac0` installs the - // file into the widget, resident or streaming — decision 2013). Until 2013 no XML-declared - // pane ever held a file: the loader read `file=` only to turn the cooldown indicator's - // pane into a native widget of ours (retired by 2019), and the pings, the shine and the - // item card were bare panes to the engine. + // A model pane's `file=` is `SetModel` (`0x76cac0`). let model_kind = super::model_kind_tag(&el.tag); if model_kind { if let Some(file) = el.attr("file") { @@ -73,6 +43,9 @@ impl Loader<'_> { self.call(wrapper, "SetModel", text, dbg); } } + // A model pane's `scale=` is the model's scale, the field `SetModelScale` writes + // (`0x76cb61`); a value of 0 or less is refused, never clamped (`0x76cb92`). A plain + // frame's `scale=` is ignored; whether `0x769820` reads one is untraced. if let Some(scale) = el.attr("scale") { if model_kind { match scale.trim().parse::() { @@ -84,12 +57,8 @@ impl Loader<'_> { } } } - // The model pane's own fog attributes and `` child (`CSimpleModel::LoadXML` - // `0x76cac0`). `fogNear`/`fogFar` are **clamped at `≥ 0`** here and only - // here (`76cbbb`-`76cbd2` / `76cbf3`-`76cc0a`: `0.0 fcomp value ; jne store ; else store - // 0.0`) — the Lua setters store raw. The `` child writes the packed colour AND - // arms the fog bit, so it is `SetFogColor` in every respect; nothing in XML touches the - // light. Decision 2027. + // A model pane's fog: `fogNear`/`fogFar` clamp at 0 here only (`0x76cbbb`, `0x76cbf3`), + // where the Lua setters store raw; `` is `SetFogColor`, fog bit included. if model_kind { for (attr, verb) in [("fogNear", "SetFogNear"), ("fogFar", "SetFogFar")] { if let Some(v) = el.attr(attr).and_then(|v| v.trim().parse::().ok()) { @@ -115,22 +84,14 @@ impl Loader<'_> { self.call(wrapper, "SetID", n, dbg); } } - // `clampedToScreen="true"` → SetClampedToScreen (`0x768cc0` — geometry flags bit4, - // the layout resolve's screen clamp; GameTooltip frames carry it by construction). + // `clampedToScreen`: the layout's screen clamp, geometry flag bit 4 (`0x768cc0`). if el.attr_bool("clampedToScreen") { self.call(wrapper, "SetClampedToScreen", true, dbg); } - // `enableMouse` lands as the real EnableMouse call (the hit test gates on it — - // pointer.rs): a ref frame authored mouse-blocking (StaticPopup, BlackoutWorld, the - // minimap cluster) must actually capture. The loader ignored it for a while after the - // native landed — the stale "v1 gap" note here — which left those frames click-through - // and the minimap deaf to its ping click (0434 phase 6c's root cause). if el.attr_bool("enableMouse") { self.call(wrapper, "EnableMouse", true, dbg); } - // `` (also accepted - // inline on the element) → SetHitRectInsets: the frame's MOUSE rect, inset from its - // resolved rect. Absent sides read 0, so a partial element insets only what it names. + // ``: the mouse rect, inset from the frame's; a side it omits is 0. if let Some(ins) = children_named(el, "HitRectInsets").next() { let src = children_named(ins, "AbsInset").next().unwrap_or(ins); let side = |k: &str| { @@ -145,70 +106,33 @@ impl Loader<'_> { dbg, ); } - // `…` → the resize - // quad (`0x769820`'s child-loop arm at `0x769baa`). Three clauses from the reference - // rather than from the shape of the element: - // - // · **Both pairs are written unconditionally once `` matches**, each side - // defaulting to `0` before its lookup — so a block carrying only `` RESETS - // the max pair to unbounded. That is why this writes both calls, never just the one it - // found. - // · `x` is the WIDTH and `y` the HEIGHT, through the same logical→internal transform - // `` uses — the same `abs_dim` reader, and the same inline-or-`AbsDimension` pair - // of forms. - // · `` and `` are consumed by the base-class `CLayoutFrame::LoadXML` - // *before* the child loop is entered at all, so they always precede this structurally - // whatever the document order — and neither path clamps, so an authored `` - // outside the authored bounds survives load unchanged. - // - // The shipped 1.12.1 FrameXML uses it exactly once (`FloatingChatFrame.xml:223`, - // min 296×75 / max 608×400) and never calls the Lua setters at all. + // `` (`0x769baa`): both pairs are always written, an absent one as 0, so a + // lone `` resets the max to unbounded. A `` outside them is not clamped. if let Some(rb) = children_named(el, "ResizeBounds").next() { for (tag, verb) in [("minResize", "SetMinResize"), ("maxResize", "SetMaxResize")] { let (x, y) = children_named(rb, tag).next().map_or((None, None), abs_dim); self.call(wrapper, verb, (x.unwrap_or(0.0), y.unwrap_or(0.0)), dbg); } } - // `movable`/`resizable` → SetMovable/SetResizable — the same flag word the methods write - // (`0x76a3c0` with mask 0x100 / 0x200; the XML loader `0x769820` calls that very setter). - // Both were in the gap list below until the movable family - // landed; leaving them there would have left every `movable="true"` reference window - // undraggable while `SetMovable` worked from Lua. + // `movable`/`resizable`: flag bits 0x100/0x200, via the setter Lua uses (`0x76a3c0`). if el.attr_bool("movable") { self.call(wrapper, "SetMovable", true, dbg); } if el.attr_bool("resizable") { self.call(wrapper, "SetResizable", true, dbg); } - // `toplevel` → SetToplevel — the third bit of that same flag word (`0x76a3c0` mask `0x1`, - // XML site `0x7698ec`), and the third attribute to - // graduate out of the gap list below for the same reason: the raise law is built - // (`script::object::toplevel`), so accepting the attribute now means the behaviour, not - // silence. 82 corpus addons and thirteen of our own frames declare it. + // `toplevel`: bit 0x1 of the same flag word (`0x7698ec`). if el.attr_bool("toplevel") { self.call(wrapper, "SetToplevel", true, dbg); } - // `enableKeyboard="true"` — the XML half of the flag, which enables BOTH key kinds - // (`0x769ae8`/`0x769af3`). The flag is real, and `script::keyboard`'s delivery - // walk is what reads it (1319). + // `enableKeyboard` enables both key kinds (`0x769ae8`, `0x769af3`). if el.attr_bool("enableKeyboard") { self.call(wrapper, "EnableKeyboard", true, dbg); } } - /// `` → SetWidth/SetHeight (`0x767800`). Accepts either `` - /// or the inline `` form; a dimension that's absent is left untouched (the client's - /// "0 = derive"). ALL `` children apply, in document order — template expansion appends the - /// instance's children after the template's ([`crate::framexml::expand`]), and the client simply - /// processes each child in turn, so an instance's own `` overwrites its template's. (Taking - /// only `.next()` here silently pinned every templated frame to the TEMPLATE's size; caught on - /// the quest log's Abandon button — 125×21 in the instance XML, 80×22 on screen.) - /// `` (or one with its own ``/``): the - /// frame's drag handle, built through the same `CreateTitleRegion` the Lua API exposes — so - /// the element and a later `frame:CreateTitleRegion()` name ONE object (XML site `0x769b2a` - /// is the same code path as `CreateTitleRegion 0x773910`, idempotent) — then laid out like - /// any region. - /// The stock `TutorialFrame.xml` declares one over its whole plate (1976). + /// ``: the drag handle, built through the idempotent `CreateTitleRegion` so the + /// element and a later Lua call name one object (`0x769b2a` takes `0x773910`'s path). pub(super) fn apply_title_region( &mut self, el: &Element, @@ -231,6 +155,8 @@ impl Loader<'_> { self.apply_region_layout(tr, ®ion, self_name, dbg, FontAttrs::Own); } + /// `` (`0x767800`). Every one applies in document order, and expansion puts an + /// instance's children after its template's, so the instance's own `` wins. pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) { for size in children_named(el, "Size") { let (x, y) = abs_dim(size); @@ -243,13 +169,9 @@ impl Loader<'_> { } } - /// `` → SetPoint per `` - /// (`0x767800`). `relativePoint` defaults to `point`; `relativeTo` is `$parent`-substituted - /// (`0x76c5b0`) and passed by name (the object model resolves it, falling back to the parent - /// when absent/unresolved); a missing `point` is skipped with a warning ("Invalid anchor - /// point"). - /// The `setAllPoints="true"` shorthand applies first, like the region path — a frame carrying - /// only the attribute (WorldMapFrame's chrome layers) pins TOPLEFT+BOTTOMRIGHT to its parent. + /// `` (`0x767800`), after the `setAllPoints` shorthand: `relativePoint` defaults to + /// `point`, and `relativeTo` expands `$parent` to the nearest named ancestor's name, else + /// `Top` (`0x76c5b0`). pub(super) fn apply_anchors( &mut self, el: &Element, @@ -289,8 +211,7 @@ impl Loader<'_> { args, dbg: dbg.to_string(), }; - // The XML path's own law, which resolves the name itself and defers a target - // the enclosing frame's subtree has not built yet (`Loader::apply_anchor`). + // Deferred while the enclosing subtree has not built its target yet. self.apply_anchor(d, true); } } diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 3fd125c6..435bbff9 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -1,58 +1,12 @@ -//! The FrameXML **loader** (decision 0068): the join between the document layer ([`crate::framexml`]) -//! and the runtime ([`crate::script`]). It walks a parsed FrameXML document and *materializes* it — -//! turning ``/`