Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)
Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8505187668
commit
fd386e75ae
@@ -1,11 +1,7 @@
|
||||
//! **The `index or "name"` prologue every in-game addon verb opens with, and its two raises**
|
||||
//! (2139's "left open", closed at the bytes).
|
||||
//!
|
||||
//! Eight bindings — `GetAddOnInfo 0x48e390`, `GetAddOnMetadata 0x48e530`,
|
||||
//! The `index or "name"` prologue of `GetAddOnInfo 0x48e390`, `GetAddOnMetadata 0x48e530`,
|
||||
//! `GetAddOnDependencies 0x48e5e0`, `EnableAddOn 0x48e690`, `DisableAddOn 0x48e760`,
|
||||
//! `IsAddOnLoadOnDemand 0x48e840`, `IsAddOnLoaded 0x48e8e0`, `LoadAddOn 0x48e980` — share one
|
||||
//! eleven-instruction prologue, and `luaL_error 0x6f4940` does not return from either of its
|
||||
//! failure arms. We answered a placeholder, a `nil` or a silent no-op for all of them.
|
||||
//! `IsAddOnLoadOnDemand 0x48e840`, `IsAddOnLoaded 0x48e8e0` and `LoadAddOn 0x48e980`; both its
|
||||
//! raises are `luaL_error` (`0x6f4940`), which does not return.
|
||||
|
||||
use crate::script::{AddOnInfo, UiScript};
|
||||
|
||||
@@ -31,15 +27,11 @@ fn seeded() -> UiScript {
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// **The index space exists only once the server has answered** (decision 2175). These tests
|
||||
// are about the prologue's bounds and raises, and a bound of zero would make every one of
|
||||
// them pass vacuously — so the fixture seats the reply an in-world VM has always had, hiding
|
||||
// nothing.
|
||||
// The index space exists only once the server answers; with no reply every bound is zero.
|
||||
s.note_addon_info_reply(&[]);
|
||||
s
|
||||
}
|
||||
|
||||
/// The message of a raise, or `"<no raise>"` if the call completed.
|
||||
fn raised(s: &UiScript, call: &str) -> String {
|
||||
s.eval::<String>(&format!(
|
||||
"local ok, e = pcall(function() {call} end) \
|
||||
@@ -48,9 +40,7 @@ fn raised(s: &UiScript, call: &str) -> String {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// **Every verb raises its own `Usage:` literal on a non-number, non-string argument.** Read out
|
||||
/// of `.data` (`0x842d68` … `0x842e98`) rather than reconstructed: the message reaches an addon's
|
||||
/// error handler, so the spelling is the contract.
|
||||
/// The client's own literals (`.data` `0x842d68` to `0x842e98`); addons see the spelling.
|
||||
#[test]
|
||||
fn a_bad_argument_type_raises_each_verbs_own_usage_string() {
|
||||
let s = seeded();
|
||||
@@ -82,8 +72,7 @@ fn a_bad_argument_type_raises_each_verbs_own_usage_string() {
|
||||
"{call} must raise `{usage}`, got: {got}"
|
||||
);
|
||||
}
|
||||
// `nil` and a missing argument fail the same two tests a table does — `lua_isnumber` reports
|
||||
// NULL past `L->top` as "not a number" — so they take the same raise.
|
||||
// `lua_isnumber` reports a slot past `L->top` as not a number, so nil or no argument raises.
|
||||
for call in ["LoadAddOn(nil)", "LoadAddOn()", "IsAddOnLoaded(true)"] {
|
||||
assert!(
|
||||
raised(&s, call).contains("index or \"name\""),
|
||||
@@ -91,16 +80,12 @@ fn a_bad_argument_type_raises_each_verbs_own_usage_string() {
|
||||
raised(&s, call)
|
||||
);
|
||||
}
|
||||
// The second argument has its own `lua_isstring` (`0x48e59c`) onto the SAME raise.
|
||||
// The second argument's own `lua_isstring` (`0x48e59c`) fails onto the same raise.
|
||||
assert!(raised(&s, "GetAddOnMetadata(1)").contains("Usage: GetAddOnMetadata"));
|
||||
}
|
||||
|
||||
/// **An out-of-range numeric index raises, on every verb, with the count in the message** —
|
||||
/// `"AddOn index must be in the range of 1 to %d"` (`0x837d70`), `%d` from `0x51def0()`.
|
||||
///
|
||||
/// The bound is **unsigned** (`0x51df00 cmp ecx,[0xbe1b90]; jb`), so `f(0)` decrements to
|
||||
/// `0xFFFFFFFF` and raises by the same route as `f(count+1)`. Only three of the eight raised at
|
||||
/// all before this, and those three said something the image does not.
|
||||
/// `"AddOn index must be in the range of 1 to %d"` (`0x837d70`, `%d` from `0x51def0()`). The
|
||||
/// bound is unsigned (`0x51df00 cmp ecx,[0xbe1b90]; jb`): 0 wraps to `0xFFFFFFFF` and raises.
|
||||
#[test]
|
||||
fn an_out_of_range_index_raises_with_the_registrys_own_count() {
|
||||
let s = seeded();
|
||||
@@ -115,8 +100,7 @@ fn an_out_of_range_index_raises_with_the_registrys_own_count() {
|
||||
"LoadAddOn",
|
||||
] {
|
||||
for arg in ["0", "3", "-1", "2.9"] {
|
||||
// 2.9 truncates toward zero (`_ftol 0x40a2b0` chops) to 2, which is IN range — the
|
||||
// one of the four that must NOT raise.
|
||||
// 2.9 truncates to 2 (`_ftol 0x40a2b0`), which is in range.
|
||||
let got = raised(&s, &format!("{verb}({arg})"));
|
||||
if arg == "2.9" {
|
||||
assert!(!got.contains("must be in the range"), "{verb}(2.9): {got}");
|
||||
@@ -128,8 +112,7 @@ fn an_out_of_range_index_raises_with_the_registrys_own_count() {
|
||||
assert!(raised(&s, "GetAddOnMetadata(3, \"Version\")").contains(want));
|
||||
}
|
||||
|
||||
/// **A numeric STRING is an index, not a name** — `lua_isnumber 0x6f34d0` coerces one, so the
|
||||
/// number arm claims it before `lua_isstring` is ever reached.
|
||||
/// `lua_isnumber` (`0x6f34d0`) coerces a numeric string, so the index arm claims it first.
|
||||
#[test]
|
||||
fn a_numeric_string_takes_the_index_arm() {
|
||||
let s = seeded();
|
||||
@@ -141,11 +124,7 @@ fn a_numeric_string_takes_the_index_arm() {
|
||||
assert!(raised(&s, r#"GetAddOnInfo("9")"#).contains("must be in the range"));
|
||||
}
|
||||
|
||||
/// **`GetAddOnInfo`'s string miss echoes the caller's own name back** (`0x48e401`, non-NULL by
|
||||
/// `lua_isstring`), then five metadata misses, `"MISSING"` and `"INSECURE"`. We answered the
|
||||
/// literal `"NoSuchAddon"` — an arbitrary choice, not a constant in the image.
|
||||
///
|
||||
/// The name form is never existence-checked by the prologue; only the numeric one is.
|
||||
/// A name miss echoes the caller's string (`0x48e401`); only an index is existence-checked.
|
||||
#[test]
|
||||
fn an_unknown_name_echoes_itself_rather_than_a_placeholder_literal() {
|
||||
let s = seeded();
|
||||
@@ -165,7 +144,7 @@ fn an_unknown_name_echoes_itself_rather_than_a_placeholder_literal() {
|
||||
"INSECURE".into(),
|
||||
]
|
||||
);
|
||||
// The other name-miss answers are each verb's own, and they do not agree with GetAddOnInfo's.
|
||||
// Each verb has its own name-miss answer.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return tostring(IsAddOnLoaded("Nope"))"#)
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
//! **The Lua index space is not the registry** (decision 2175) — a different order, over a
|
||||
//! different set, that does not exist until the server answers.
|
||||
//!
|
||||
//! `GetNumAddOns` and the *index* form of every AddOn verb address a flat array the client
|
||||
//! rebuilds in exactly one place: the tail of `AddOn_ReadAddonInfoReply 0x51da70`
|
||||
//! (`[0x51dc30, 0x51dcdf)`), driven by `SMSG_ADDON_INFO`. It is `## Title`-sorted with
|
||||
//! `SStrCmpI`, filtered by `[rec+0x29]`, and empty before the reply — three properties benilla had
|
||||
//! none of, because it indexed the registry list directly (the binary's own `qsort 0x73f727` and
|
||||
//! comparator `0x51deb0`).
|
||||
//! The addon index space that `GetNumAddOns` and every verb's index form address: an array, not
|
||||
//! the registry, rebuilt only by `SMSG_ADDON_INFO` (`AddOn_ReadAddonInfoReply 0x51da70`,
|
||||
//! `[0x51dc30, 0x51dcdf)`). It is `## Title`-sorted with `SStrCmpI` (`qsort 0x73f727`, comparator
|
||||
//! `0x51deb0`), filtered by `[rec+0x29]`, and empty before the reply.
|
||||
|
||||
use crate::script::{AddOnInfo, UiScript};
|
||||
|
||||
/// Three addons whose FOLDER order and TITLE order are deliberately different permutations, so a
|
||||
/// test cannot pass by accident on a registry that happens to be sorted.
|
||||
///
|
||||
/// Folder order (registration): `Zulu`, `Alpha`, `Mike`.
|
||||
/// Title order: `Zulu` (`"aardvark"`), `Mike` (`"Middle"`), `Alpha` (`"zebra"`).
|
||||
/// Folder order `Zulu`, `Alpha`, `Mike`; title order `Zulu`, `Mike`, `Alpha`.
|
||||
fn seeded() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let row = |name: &str, title: Option<&str>| AddOnInfo {
|
||||
@@ -37,7 +28,6 @@ fn seeded() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// The names `GetAddOnInfo(i)` answers, in index order.
|
||||
fn by_index(s: &UiScript) -> Vec<String> {
|
||||
s.eval::<Vec<String>>(
|
||||
"local t = {} for i = 1, GetNumAddOns() do t[i] = (GetAddOnInfo(i)) end return t",
|
||||
@@ -45,14 +35,8 @@ fn by_index(s: &UiScript) -> Vec<String> {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// **The index space is `## Title`-sorted, case-insensitively — not folder order.**
|
||||
///
|
||||
/// Comparator `0x51deb0` resolves both sides through `AddOn_GetTitle 0x51df20` and compares with
|
||||
/// `SStrCmpI` (`0x64a4c0` → `_strnicmp 0x414310`, which folds `'A'..'Z'` by `+0x20` on both
|
||||
/// operands — ASCII only, which is what `to_ascii_lowercase` is).
|
||||
///
|
||||
/// `"Middle"` is the case control: a byte-wise sort would put every capital before every
|
||||
/// lowercase and answer `Middle, aardvark, zebra`.
|
||||
/// Comparator `0x51deb0` compares `AddOn_GetTitle` (`0x51df20`) with `SStrCmpI` (`0x64a4c0`,
|
||||
/// `_strnicmp 0x414310`), folding ASCII only; a byte-wise sort would put `"Middle"` first.
|
||||
#[test]
|
||||
fn the_index_space_is_title_sorted_case_insensitively_and_not_registry_order() {
|
||||
let mut s = seeded();
|
||||
@@ -60,9 +44,8 @@ fn the_index_space_is_title_sorted_case_insensitively_and_not_registry_order() {
|
||||
assert_eq!(by_index(&s), vec!["Zulu", "Mike", "Alpha"]);
|
||||
}
|
||||
|
||||
/// **A record with no `## Title` sorts under its FOLDER name**, which is the comparator's
|
||||
/// fallback: `AddOn_GetTitle` misses (`0x51e046` returns 0) and `0x51ded0`/`0x51ded6` substitute
|
||||
/// the name the array holds.
|
||||
/// The comparator's fallback: `AddOn_GetTitle` misses (`0x51e046`) and `0x51ded0`/`0x51ded6`
|
||||
/// substitute the folder name.
|
||||
#[test]
|
||||
fn a_titleless_addon_sorts_under_its_folder_name() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -75,7 +58,6 @@ fn a_titleless_addon_sorts_under_its_folder_name() {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
// No title at all — sorts as "bravo", between nothing and "zzz".
|
||||
AddOnInfo {
|
||||
name: "bravo".into(),
|
||||
interface: 11200,
|
||||
@@ -91,11 +73,8 @@ fn a_titleless_addon_sorts_under_its_folder_name() {
|
||||
assert_eq!(by_index(&s), vec!["bravo", "Yankee"]);
|
||||
}
|
||||
|
||||
/// **There is no index space until `SMSG_ADDON_INFO` arrives** — `[0xbe1b90]` is zeroed by the
|
||||
/// registry reset `0x51fad1` and written nowhere but the reply's own rebuild.
|
||||
///
|
||||
/// The registry is fully populated here; only the array is absent. The raise is the same one an
|
||||
/// out-of-range index takes, because it *is* the same unsigned bound against a count of zero.
|
||||
/// The count `[0xbe1b90]` is zeroed by the registry reset (`0x51fad1`) and written only by the
|
||||
/// reply's rebuild, so before it an index raises the ordinary range error against 0.
|
||||
#[test]
|
||||
fn there_is_no_index_space_until_the_server_answers() {
|
||||
let s = seeded();
|
||||
@@ -110,25 +89,19 @@ fn there_is_no_index_space_until_the_server_answers() {
|
||||
raised.contains("AddOn index must be in the range of 1 to 0"),
|
||||
"{raised}"
|
||||
);
|
||||
// The registry is there all along — the NAME form never went through the array, it probes the
|
||||
// master registry hash (`0x51df20`'s own lookup), so it answers for an addon the index space
|
||||
// cannot reach.
|
||||
// The name form probes the registry hash (`0x51df20`), not the array.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetAddOnInfo('Alpha'))").ok(),
|
||||
Some("Alpha".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// **A record the server hid is gone from the index space and still answers by name.**
|
||||
///
|
||||
/// `status = 2` sets `[rec+0x29] = 1` (`0x51db84`) and the rebuild's `0x51dc4f`/`0x51dc54` drops
|
||||
/// it. On a stock install that is all twelve `Blizzard_*` addons, which is why the reference's own
|
||||
/// AddOns list shows the player's addons and none of Blizzard's — and it is why the name form has
|
||||
/// to keep working, since `LoadAddOn("Blizzard_TalentUI")` is how the talent window opens.
|
||||
/// `status = 2` sets `[rec+0x29]` (`0x51db84`) and the rebuild drops it (`0x51dc4f`/`0x51dc54`);
|
||||
/// a stock install hides the twelve `Blizzard_*` addons this way, and they still load by name.
|
||||
#[test]
|
||||
fn a_hidden_addon_leaves_the_index_space_but_still_answers_by_name() {
|
||||
let mut s = seeded();
|
||||
s.note_addon_info_reply(&["mike".into()]); // case-insensitive, as every name compare here is
|
||||
s.note_addon_info_reply(&["mike".into()]); // names match case-insensitively
|
||||
assert_eq!(s.eval::<i64>("return GetNumAddOns()").ok(), Some(2));
|
||||
assert_eq!(by_index(&s), vec!["Zulu", "Alpha"]);
|
||||
assert_eq!(
|
||||
@@ -137,8 +110,6 @@ fn a_hidden_addon_leaves_the_index_space_but_still_answers_by_name() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **An empty reply is not the same as no reply.** A server that hides nothing still brings the
|
||||
/// array into existence; only silence leaves it at zero.
|
||||
#[test]
|
||||
fn a_reply_that_hides_nothing_still_builds_the_array() {
|
||||
let mut s = seeded();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! SetPoint + resolve → GetWidth/GetHeight + rect.
|
||||
//! `SetPoint` and the anchor resolve: sizes, rects, and the reference's `SetPoint` raises.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Rect;
|
||||
|
||||
// Regression: an *explicit* `nil` relativeTo must still consume its argument slot, so the
|
||||
// relativePoint + offsets that follow line up. The `SetPoint("P", nil, "P", x, y)` form is the
|
||||
// common FrameXML idiom for "anchor to the screen at an offset"; a leading nil that failed to
|
||||
// advance the cursor silently dropped the offsets (screen-anchored frames pinned to the corner).
|
||||
// FrameXML's anchor-to-the-screen idiom, `SetPoint("P", nil, "P", x, y)`: the explicit `nil`
|
||||
// still takes its argument slot, so the offsets after it apply.
|
||||
#[test]
|
||||
fn setpoint_explicit_nil_relative_to_keeps_offsets() {
|
||||
let mut s = script();
|
||||
@@ -68,22 +66,16 @@ fn setpoint_resolve_size_and_rect() {
|
||||
#[test]
|
||||
fn getwidth_falls_back_to_explicit_size_before_resolve() {
|
||||
let s = script();
|
||||
// No SetPoint ⇒ unresolvable; GetWidth returns the explicit SetWidth value.
|
||||
let w: f32 = s
|
||||
.eval(r#"local f = CreateFrame("Frame"); f:SetWidth(123); return f:GetWidth()"#)
|
||||
.unwrap();
|
||||
assert_eq!(w, 123.0);
|
||||
}
|
||||
|
||||
/// A *named* `relativeTo` that does not resolve **RAISES** and abandons the call — the reference's
|
||||
/// `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't find region named '%s'")`, with no fallback and
|
||||
/// no no-op (decision 2176; 2105 pinned the bytes and deferred the change until the addon survey
|
||||
/// could see engine warnings). The region path is the same registered function, so it raises the
|
||||
/// same way — and both quote the receiver's own name, `<unnamed>` (`0x84c7f0`) when it has none.
|
||||
///
|
||||
/// **This replaces a parent/owner fallback plus a warning.** That fallback is what misdirected
|
||||
/// ItemTextFrame's scrollbar track onto the parchment; the XML half of the same miss is a
|
||||
/// different law and keeps its warning (`Loader::apply_anchor`, `0x767800`).
|
||||
/// A named `relativeTo` that does not resolve raises and abandons the call, with no fallback
|
||||
/// (`0x87ccd4`); the region path raises the same, both quoting the receiver's name, `<unnamed>`
|
||||
/// (`0x84c7f0`) when it has none. An XML anchor's miss warns instead (`Loader::apply_anchor`,
|
||||
/// `0x767800`).
|
||||
#[test]
|
||||
fn setpoint_unresolved_name_raises() {
|
||||
let mut s = script();
|
||||
@@ -110,12 +102,11 @@ fn setpoint_unresolved_name_raises() {
|
||||
"region path (anonymous receiver): {e}"
|
||||
);
|
||||
|
||||
// The anchor was ABANDONED, not applied to the parent: the frame still has none.
|
||||
let n: i64 = s.eval("return Orphan:GetNumPoints()").unwrap();
|
||||
assert_eq!(n, 0, "the raise must leave no anchor behind");
|
||||
|
||||
// `SetAllPoints` has its own string, and a NUMBER takes the name path there (`lua_isstring`)
|
||||
// where `SetPoint` would read it as an offset.
|
||||
// `SetAllPoints` has its own message, and a number takes the name path there (`lua_isstring`)
|
||||
// where `SetPoint` reads it as an offset.
|
||||
let e = s
|
||||
.run(r#"Orphan:SetAllPoints("NoSuchFrame")"#)
|
||||
.unwrap_err()
|
||||
@@ -130,22 +121,16 @@ fn setpoint_unresolved_name_raises() {
|
||||
"a number is a string to `lua_isstring`, so it is looked up as `_G[\"42\"]`: {e}"
|
||||
);
|
||||
|
||||
// A resolvable name still works, and warns about nothing.
|
||||
s.run(r#"CreateFrame("Frame", "Target"); Orphan:SetPoint("BOTTOMLEFT", "Target", "TOPLEFT")"#)
|
||||
.unwrap();
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// The other four raises of the same ladder, each read out of the reference image.
|
||||
///
|
||||
/// * **Usage** `0x87cc28` — an absent `relativeTo` is `TNONE` and fails the `lua_type(L,3)` gate
|
||||
/// (`0x7a25e6`–`0x7a2620`), so a one-argument `SetPoint` is an error on a 1.12 client. Stock
|
||||
/// content makes none (349 call sites, arity histogram `{3:5, 4:1, 5:343}`); five sites in the
|
||||
/// 219-addon vanilla corpus do, all of them in multi-client code.
|
||||
/// * **Unknown region point** `0x87cd04`, from the 9-entry scan `0x6f1840`.
|
||||
/// * **trying to anchor to itself** `0x87cca8` / `0x87cd54`.
|
||||
/// * And the ORDER is observable: the two tag gates run before the point-name scan, so an unknown
|
||||
/// point with an absent `relativeTo` answers Usage, not "Unknown region point".
|
||||
/// The reference's other `SetPoint` raises: Usage (`0x87cc28`) when `relativeTo` is absent, since
|
||||
/// `lua_type(L,3)` is `TNONE` at the gate (`0x7a25e6-0x7a2620`); "Unknown region point"
|
||||
/// (`0x87cd04`, the 9-entry scan `0x6f1840`); "trying to anchor to itself" (`0x87cca8`,
|
||||
/// `0x87cd54`). The type gates run before the point scan, so an unknown point with no
|
||||
/// `relativeTo` answers Usage.
|
||||
#[test]
|
||||
fn the_other_setpoint_raises() {
|
||||
let s = script();
|
||||
@@ -163,7 +148,6 @@ fn the_other_setpoint_raises() {
|
||||
.to_string();
|
||||
assert!(e.contains("Ladder:SetPoint(): Unknown region point"), "{e}");
|
||||
|
||||
// Unknown point AND absent relativeTo: the tag gate is first, so this is Usage.
|
||||
let e = s
|
||||
.run(r#"Ladder:SetPoint("MIDDLE")"#)
|
||||
.unwrap_err()
|
||||
@@ -180,18 +164,14 @@ fn the_other_setpoint_raises() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A trailing explicit `nil` is PRESENT, and absent is absent** — the one thing that would make
|
||||
/// the Usage raise over-fire. `lua_type(L,3)` distinguishes `LUA_TNIL` (0, the screen-root arm)
|
||||
/// from `LUA_TNONE` (−1, which fails the gate), so the ladder must see the argument count Lua saw
|
||||
/// and not a tuple padded or trimmed on the way in.
|
||||
/// `lua_type(L,3)` tells `LUA_TNIL` (0, the screen root) from `LUA_TNONE` (-1, Usage), so the
|
||||
/// raise ladder must see the argument count Lua saw, not a padded or trimmed tuple.
|
||||
#[test]
|
||||
fn a_trailing_nil_relative_to_is_present_where_an_absent_one_is_not() {
|
||||
let s = script();
|
||||
s.run(r#"f = CreateFrame("Frame", "Trail")"#).unwrap();
|
||||
// Present-and-nil: legal, the screen root, no offsets.
|
||||
s.run(r#"Trail:SetPoint("CENTER", nil)"#).unwrap();
|
||||
assert_eq!(s.eval::<i64>("return Trail:GetNumPoints()").unwrap(), 1);
|
||||
// Absent: Usage.
|
||||
let e = s
|
||||
.run(r#"Trail:SetPoint("CENTER")"#)
|
||||
.unwrap_err()
|
||||
@@ -199,10 +179,8 @@ fn a_trailing_nil_relative_to_is_present_where_an_absent_one_is_not() {
|
||||
assert!(e.contains("Usage:"), "{e}");
|
||||
}
|
||||
|
||||
/// An explicit `nil` `relativeTo` is the **SCREEN ROOT** (`0x7a2710 mov eax,ds:0xcf0bd8`), not the
|
||||
/// parent — the arm benilla answered with the parent until 2176. `UIParent.lua:1549` is the one
|
||||
/// stock site; fourteen corpus sites take it, every one of them meaning "the screen"
|
||||
/// (`pfUI`'s screenshot caption, `FixCTGroups`' cursor-space plate).
|
||||
/// An explicit `nil` `relativeTo` is the screen root, not the parent (`0x7a2710` loads it from
|
||||
/// `0xcf0bd8`); the one stock site is `UIParent.lua:1549`.
|
||||
#[test]
|
||||
fn an_explicit_nil_relative_to_is_the_screen_not_the_parent() {
|
||||
let mut s = script();
|
||||
@@ -226,21 +204,8 @@ fn an_explicit_nil_relative_to_is_the_screen_not_the_parent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A region whose OWNER has no rect still resolves from its own anchors.**
|
||||
///
|
||||
/// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame.
|
||||
/// But the owner's rect is only the *fallback* for axes the region's own anchors do not pin — a
|
||||
/// region anchored fully to some other frame needs nothing from its owner, and the reference
|
||||
/// resolves it. Skipping made an ordinary addon shape silently invisible: a bare container frame
|
||||
/// (`CreateFrame("Frame", n, parent)` with no size and no `SetPoint`) holding a region anchored
|
||||
/// elsewhere.
|
||||
///
|
||||
/// Found by reproducing the director's MapCoords report. Its world-map readout computed the right
|
||||
/// string every single frame — `Cursor Coords: … Player Coords: …` — and was never given a
|
||||
/// position, with no error raised anywhere. Three of its four frames are this shape.
|
||||
///
|
||||
/// The control matters as much as the subject: a SIZED owner must be unaffected, or the fix has
|
||||
/// traded one wrong layout for another.
|
||||
/// A region resolves from its own anchors and size; its owner supplies only the scale, so a region
|
||||
/// anchored elsewhere resolves under an unpositioned owner. A sized owner is the control.
|
||||
#[test]
|
||||
fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -268,14 +233,13 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// The owner genuinely has no rect — that part is correct and must stay true.
|
||||
assert_eq!(
|
||||
s.eval::<Option<f32>>("return BareOwner:GetLeft()").unwrap(),
|
||||
None,
|
||||
"an unpositioned frame has no rect; the fix must not invent one for it"
|
||||
);
|
||||
|
||||
// ...and its region resolves anyway, from its own anchor: host's BOTTOMLEFT (100,50) + (5,7).
|
||||
// Host's BOTTOMLEFT (100,50) plus the (5,7) offset.
|
||||
assert_eq!(
|
||||
s.eval::<Option<f32>>("return BareMark:GetLeft()").unwrap(),
|
||||
Some(105.0),
|
||||
@@ -286,7 +250,6 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
|
||||
.unwrap(),
|
||||
Some(57.0)
|
||||
);
|
||||
// The control is unmoved.
|
||||
assert_eq!(
|
||||
s.eval::<Option<f32>>("return SizedMark:GetLeft()").unwrap(),
|
||||
Some(105.0),
|
||||
@@ -294,19 +257,10 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **…but a region the owner's rect is the ONLY candidate for stays unresolved with it.**
|
||||
///
|
||||
/// The other half of the law above, and the half that shipped wrong: an unpinned axis on an
|
||||
/// unpositioned owner has nothing to fall back to, so standing a zero rect in for the missing owner
|
||||
/// puts the region at the **screen origin** instead of nowhere. That is not a degenerate rect, it is
|
||||
/// a wrong position — and a template whose textures chain off each other turns it into real,
|
||||
/// visible geometry a few links down.
|
||||
///
|
||||
/// The symptom: opening the social pane draws a stray dropdown capsule at
|
||||
/// the bottom of the screen next to the action bar. `FriendsDropDown` carries no anchors —
|
||||
/// *exactly* as the reference's own `FriendsDropDown` does (`FriendsFrame.xml` l.598), and the
|
||||
/// reference draws nothing — so every texture of `UIDropDownMenuTemplate` hung off a phantom rect
|
||||
/// at (0,0). The shape below is that template's first two textures verbatim.
|
||||
/// A region anchored to an unpositioned owner has no rect, nor does the chain off it; a zero rect
|
||||
/// in its place would draw at the screen origin. The shape is `UIDropDownMenuTemplate`'s first two
|
||||
/// textures under the anchorless `FriendsDropDown` (`FriendsFrame.xml:598`), which the reference
|
||||
/// does not draw.
|
||||
#[test]
|
||||
fn an_unanchored_owners_region_chain_resolves_nowhere() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -349,24 +303,14 @@ fn an_unanchored_owners_region_chain_resolves_nowhere() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Geometry answers inside the same call stack that moved it** — the seam that killed 97 addons.
|
||||
///
|
||||
/// A handler creates a frame, anchors it, shows it and then measures it, all before any resolve
|
||||
/// pass can run. `Dewdrop-2.0.lua` — embedded in ~65 corpus addons — does exactly this in its menu
|
||||
/// `Open`: `local left = frame:GetLeft()` (l.1942) then `curX - left - width/2` (l.1960). Our
|
||||
/// getters read a cache filled by the per-frame `resolve()`, so `left` was nil and the arithmetic
|
||||
/// died. **97 of the 108 addons that drew and then raised on being touched died on that one line**,
|
||||
/// with no missing verb anywhere.
|
||||
///
|
||||
/// The readers settle the graph on demand now. The second half of the test is the one that keeps
|
||||
/// it honest: a *further* move inside the same stack must also be visible, or the fix is just a
|
||||
/// one-shot warm-up.
|
||||
/// The geometry readers settle the layout on demand, so a frame moved inside a call stack reports
|
||||
/// its new rect in that stack, again after a second move.
|
||||
#[test]
|
||||
fn geometry_answers_within_the_call_stack_that_moved_it() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
|
||||
// Dewdrop's shape, in one eval — no resolve() between the writes and the read.
|
||||
// Dewdrop-2.0's menu `Open` in one eval: no `resolve()` between the writes and the read.
|
||||
let (left, bottom): (Option<f32>, Option<f32>) = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -384,7 +328,6 @@ fn geometry_answers_within_the_call_stack_that_moved_it() {
|
||||
"a frame must report its geometry in the stack that anchored it"
|
||||
);
|
||||
|
||||
// ...and a LATER move in the same stack is visible too — not a stale first answer.
|
||||
let moved: Option<f32> = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -396,7 +339,6 @@ fn geometry_answers_within_the_call_stack_that_moved_it() {
|
||||
.unwrap();
|
||||
assert_eq!(moved, Some(200.0), "the settle must re-run after each move");
|
||||
|
||||
// Width/height and the centre reader take the same path.
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return MenuLike:GetWidth(), MenuLike:GetHeight()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Backdrop: Lua verbs + extract emission (the reference's Backdrop object, `0x77e5f0`).
|
||||
//! The backdrop Lua verbs and extract emission (the reference's Backdrop object, `0x77e5f0`).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
// SetBackdrop installs the plate; SetBackdropColor tints the bg only, SetBackdropBorderColor all 8
|
||||
// border pieces; extract emits bg-then-border at the frame's own slot with those colors.
|
||||
// SetBackdropColor tints only the bg, SetBackdropBorderColor the 8 border pieces.
|
||||
#[test]
|
||||
fn backdrop_installs_and_extracts_pieces_with_colors() {
|
||||
let mut s = script();
|
||||
@@ -35,24 +34,17 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
|
||||
.collect();
|
||||
// bg (1) + 8 border pieces.
|
||||
assert_eq!(pieces.len(), 9);
|
||||
// First is the bg, tinted the tooltip background color — QUANTIZED. The reference's colour
|
||||
// field is a packed `0xAARRGGBB` byte quad and the setter converts `×255 + 0.5` through
|
||||
// `__ftol` (`SetBackdropColor 0x777d30`), so `0.09` stores as 23 and reads back
|
||||
// as `23/255`. This assertion used to hold `0.09` exactly, which was our lossless `[f32; 4]`
|
||||
// showing through a store the client cannot make.
|
||||
// The colour is a packed `0xAARRGGBB` byte quad, stored as `×255 + 0.5` through `__ftol`
|
||||
// (`SetBackdropColor 0x777d30`), so 0.09 reads back as 23/255.
|
||||
assert_eq!(pieces[0].0, "bg");
|
||||
let q = |x: f32| f32::from((x * 255.0 + 0.5) as u8) / 255.0;
|
||||
assert_eq!(pieces[0].1, [q(0.09), q(0.09), q(0.19), 1.0]);
|
||||
// The remaining 8 are the border, white, from the edge file.
|
||||
assert!(pieces[1..]
|
||||
.iter()
|
||||
.all(|(p, c)| p == "edge" && *c == [1.0, 1.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
// `GetBackdrop()` — the four traps of `0x777370`, pinned
|
||||
// where an addon can observe them. The corpus caller is `BuffCheck2.lua:448`, which reads the table
|
||||
// back, edits `insets`, and feeds it straight to `SetBackdrop` — so a wrong `tile` type or a missing
|
||||
// ctor default does not just read wrong, it round-trips wrong.
|
||||
// The four traps of `GetBackdrop()` (`0x777370`), as an addon observes them.
|
||||
#[test]
|
||||
fn get_backdrop_reconstructs_from_the_struct() {
|
||||
let s = script();
|
||||
@@ -123,8 +115,7 @@ fn get_backdrop_reconstructs_from_the_struct() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// BuffCheck2.lua:448's exact shape: read the plate back, edit `insets`, push it straight back in.
|
||||
// It is the whole reason this method exists in our client, so it is the test that must not rot.
|
||||
// The shape of `BuffCheck2.lua:448`: read the plate back, edit `insets`, set it again.
|
||||
#[test]
|
||||
fn get_backdrop_round_trips_through_set_backdrop() {
|
||||
let mut s = script();
|
||||
@@ -151,7 +142,7 @@ fn get_backdrop_round_trips_through_set_backdrop() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// The plate still draws: bg + 8 border pieces, unchanged by the round trip.
|
||||
// bg (1) + 8 border pieces.
|
||||
assert_eq!(
|
||||
s.extract()
|
||||
.iter()
|
||||
@@ -161,7 +152,6 @@ fn get_backdrop_round_trips_through_set_backdrop() {
|
||||
);
|
||||
}
|
||||
|
||||
// SetBackdrop(nil) tears the plate down (no pieces after).
|
||||
#[test]
|
||||
fn set_backdrop_nil_tears_down() {
|
||||
let mut s = script();
|
||||
@@ -183,17 +173,8 @@ fn set_backdrop_nil_tears_down() {
|
||||
.all(|q| !matches!(q.content, QuadContent::Backdrop { .. })));
|
||||
}
|
||||
|
||||
/// **The colour setters' argument gating is asymmetric, and getting it backwards is worse than
|
||||
/// the bug it replaces** (`0x777d30` / `0x7780d0`).
|
||||
///
|
||||
/// r/g/b go through a bare `lua_tonumber` — a `nil` channel is `0.0` and the call COMPLETES.
|
||||
/// benilla typed them `f32` and raised, which killed ShaguTweaks at `helpers.lua:248`, where
|
||||
/// `color.r` comes off a table that does not always have one.
|
||||
///
|
||||
/// Alpha is different: `lua_isnumber`-gated at `0x778227` with `1.0f` staged at `0x778220`, so a
|
||||
/// missing **or nil** alpha is OPAQUE. The tempting blanket fix — "treat every nil as 0.0, like
|
||||
/// its neighbours" — would have turned every one of those borders transparent, which reads as a
|
||||
/// rendering fault rather than an API bug and is the reason this test names both halves.
|
||||
/// The colour setters (`0x777d30`, `0x7780d0`) read r/g/b with a bare `lua_tonumber` (nil is 0.0)
|
||||
/// and gate alpha on `lua_isnumber` (`0x778227`) over a staged 1.0 (`0x778220`).
|
||||
#[test]
|
||||
fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
|
||||
let s = script();
|
||||
@@ -205,7 +186,6 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A nil channel is 0.0 and the call completes — no raise.
|
||||
s.run("f:SetBackdropBorderColor(nil, 1, 1, 1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return f:GetBackdropBorderColor()")
|
||||
@@ -213,7 +193,6 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
|
||||
(0.0, 1.0, 1.0, 1.0)
|
||||
);
|
||||
|
||||
// A missing alpha is 1.0, and so is an explicit nil one — NOT 0.0.
|
||||
s.run("f:SetBackdropColor(0.2, 0.4, 0.6)").unwrap();
|
||||
let (_, _, _, a) = s
|
||||
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
|
||||
@@ -234,7 +213,6 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
|
||||
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
|
||||
.unwrap();
|
||||
assert_eq!((r, g, b), (1.0, 0.0, 0.0));
|
||||
// Quantized `×255 + 0.5` through `__ftol`: 0.5 -> 128/255, not 0.5 exactly. The field is a
|
||||
// packed 0xAARRGGBB byte quad and nothing finer survives the store.
|
||||
// Stored as a byte: 0.5 reads back as 128/255.
|
||||
assert_eq!(a, 128.0 / 255.0);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ fn button_methods_exist_only_on_buttons() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Duck-typing honesty across the class chain (the reference's per-type method sets).
|
||||
assert!(s.eval::<bool>("return f.SetText == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return f.SetChecked == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return b.SetText ~= nil").unwrap());
|
||||
@@ -22,7 +21,7 @@ fn button_methods_exist_only_on_buttons() {
|
||||
assert!(s.eval::<bool>("return b.SetValue == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return cb.SetChecked ~= nil").unwrap());
|
||||
assert!(s.eval::<bool>("return cb.SetNormalTexture ~= nil").unwrap());
|
||||
// Buttons are mouse-enabled by construction (the client ctor enables input).
|
||||
// The 1.12 client's Button constructor enables mouse input; a Frame's does not.
|
||||
assert!(s.eval::<bool>("return b:IsMouseEnabled()").unwrap());
|
||||
assert!(!s.eval::<bool>("return f:IsMouseEnabled()").unwrap());
|
||||
}
|
||||
@@ -55,22 +54,18 @@ fn button_state_textures_switch_with_interaction() {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Idle: normal only (pushed/disabled suppressed; not hovered ⇒ no highlight).
|
||||
assert_eq!(visible(&s), vec!["Interface\\N.blp".to_string()]);
|
||||
// The ButtonText always draws.
|
||||
assert!(s
|
||||
.extract()
|
||||
.iter()
|
||||
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Go")));
|
||||
|
||||
// Hover: normal + additive highlight.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
let v = visible(&s);
|
||||
assert!(
|
||||
v.contains(&"Interface\\N.blp".to_string()) && v.contains(&"Interface\\H.blp".to_string())
|
||||
);
|
||||
|
||||
// Held down over it: pushed replaces normal (highlight still hovering).
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
let v = visible(&s);
|
||||
assert!(
|
||||
@@ -78,31 +73,18 @@ fn button_state_textures_switch_with_interaction() {
|
||||
);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
|
||||
// Disabled: disabled texture only, and no highlight even under the cursor.
|
||||
s.run("StateBtn:Disable()").unwrap();
|
||||
assert_eq!(visible(&s), vec!["Interface\\D.blp".to_string()]);
|
||||
// 0, not false, and not nil — the number the real client answers.
|
||||
// The 1.12 client answers the number 0, not false or nil.
|
||||
assert_eq!(s.eval::<i64>("return StateBtn:IsEnabled()").unwrap(), 0);
|
||||
s.run("StateBtn:Enable()").unwrap();
|
||||
assert_eq!(s.eval::<String>("return StateBtn:GetText()").unwrap(), "Go");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The shown texture is STICKY: a state with no art of its own changes nothing.**
|
||||
///
|
||||
/// `SetState 0x779790` gates the hide-old step *and* the show-new step on the NEW state's slot
|
||||
/// being non-null, so the shown pointer `+0x4c4`
|
||||
/// only ever moves onto a real texture — it is never cleared, and there is no fallback path in the
|
||||
/// function at all. Three consequences, and they are one mechanism seen from three sides:
|
||||
///
|
||||
/// - a press with no `<PushedTexture>` keeps the normal art (which our old model hard-coded as a
|
||||
/// `pushed.or(normal)` fallback — the special case *was* this rule, seen from one side);
|
||||
/// - **`Disable()` with no `<DisabledTexture>` keeps the normal art** — B369, where the reference's
|
||||
/// `ReputationDetailAtWarCheckBox` stays a visible box for a faction whose war flag is locked
|
||||
/// while ours went to a bare grey label;
|
||||
/// - a state texture assigned while the button is in a *different* state is not displayed
|
||||
/// (`0x778fd0`'s `idx == [this+0x328]` gate), so a button disabled *before* it is given normal
|
||||
/// art draws nothing — the two disabled cases differ only in whether anything was ever shown.
|
||||
/// `SetState 0x779790` moves the shown texture (`+0x4c4`) only onto a non-null slot, so a press or
|
||||
/// a disable with no art of its own keeps what was shown; art written into another state's slot
|
||||
/// is stored, not shown (`0x778fd0`'s `idx == [this+0x328]` gate).
|
||||
#[test]
|
||||
fn a_state_with_no_texture_leaves_the_shown_one_standing() {
|
||||
let mut s = script();
|
||||
@@ -131,22 +113,18 @@ fn a_state_with_no_texture_leaves_the_shown_one_standing() {
|
||||
|
||||
assert_eq!(visible(&s, "StickyBtn"), n, "resting on its normal art");
|
||||
|
||||
// Pressed with no PushedTexture: SetState(2) finds a null slot, so nothing moves.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(visible(&s, "StickyBtn"), n, "a press with no pushed art");
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
s.mouse_move(400.0, 400.0);
|
||||
|
||||
// Disabled with no DisabledTexture: the same null slot, the same nothing. **B369.**
|
||||
s.run("StickyBtn:Disable()").unwrap();
|
||||
assert_eq!(
|
||||
visible(&s, "StickyBtn"),
|
||||
n,
|
||||
"a disable with no disabled art leaves the box on screen"
|
||||
);
|
||||
// Giving it disabled art NOW does show it — `0x778fd0` pushes to `+0x4c4` because the slot
|
||||
// being written IS the current state's.
|
||||
s.run("StickyBtn:SetDisabledTexture(\"Interface\\\\D.blp\")")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -157,8 +135,6 @@ fn a_state_with_no_texture_leaves_the_shown_one_standing() {
|
||||
s.run("StickyBtn:Enable()").unwrap();
|
||||
assert_eq!(visible(&s, "StickyBtn"), n, "back to normal on Enable");
|
||||
|
||||
// The other side of the same gate: a button disabled BEFORE it has normal art shows nothing,
|
||||
// because the write lands in a slot that is not the current state's.
|
||||
s.run(
|
||||
r#"
|
||||
local b = CreateFrame("Button", "BornDeadBtn")
|
||||
@@ -176,14 +152,9 @@ fn a_state_with_no_texture_leaves_the_shown_one_standing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`Disable()` switches off the HIGHLIGHT draw LAYER, not just the HighlightTexture.**
|
||||
///
|
||||
/// The helper both `Enable`/`Disable` and the constructor reach (`0x779160`) does two things: the
|
||||
/// `SetState` above, and `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer 4,
|
||||
/// written into the same `[frame+0x198]` array `Enable/DisableDrawLayer` writes. So a region the
|
||||
/// button owns in HIGHLIGHT that is not its HighlightTexture goes dark with it, and an explicit
|
||||
/// `EnableDrawLayer` brings the whole layer back on a still-disabled button. Our old rule gated
|
||||
/// the HighlightTexture alone on `enabled`, which agreed on the common case and not on this one.
|
||||
/// The enable helper `0x779160` (reached by `Enable`, `Disable` and the constructor) also writes
|
||||
/// layer 4, HIGHLIGHT, into the array `Enable/DisableDrawLayer` write (`[frame+0x198]`, `0x7791bb`
|
||||
/// → `0x76a730`), so a disable darkens every HIGHLIGHT region and `EnableDrawLayer` relights them.
|
||||
#[test]
|
||||
fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
let mut s = script();
|
||||
@@ -213,7 +184,6 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Hovered and enabled: normal art, the button's own HIGHLIGHT region, and the HighlightTexture.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
let v = art(&s);
|
||||
for want in ["Interface\\N.blp", "Interface\\Own.blp", "Interface\\H.blp"] {
|
||||
@@ -223,8 +193,6 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
);
|
||||
}
|
||||
|
||||
// Disabled under the same cursor: the layer is off, so BOTH highlight-layer regions go — and
|
||||
// the normal art stays, because no state texture moved (the sticky rule).
|
||||
s.run("LayerBtn:Disable()").unwrap();
|
||||
assert_eq!(
|
||||
art(&s),
|
||||
@@ -232,7 +200,6 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
"the whole HIGHLIGHT layer goes with the disable"
|
||||
);
|
||||
|
||||
// The layer is a layer: turning it back on lights both again, disabled or not.
|
||||
s.run(r#"LayerBtn:EnableDrawLayer("HIGHLIGHT")"#).unwrap();
|
||||
let v = art(&s);
|
||||
assert!(
|
||||
@@ -246,7 +213,6 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
"and it really is still disabled"
|
||||
);
|
||||
|
||||
// …and Enable() writes the same one array, so it undoes an addon's DisableDrawLayer.
|
||||
s.run(r#"LayerBtn:DisableDrawLayer("HIGHLIGHT") LayerBtn:Enable()"#)
|
||||
.unwrap();
|
||||
let v = art(&s);
|
||||
@@ -257,12 +223,8 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A right-click lights a button up too** — the director's report, and `0x77924b`'s law.
|
||||
///
|
||||
/// `CButton::OnMouseDown` gates the PushedTexture on `[this+0x330] & (m | m << 8)`: the button
|
||||
/// registered for that mouse button in **either** variant. Not on the click firing, not on the
|
||||
/// handler doing anything — which is why right-clicking an action, spellbook or pet slot flashes,
|
||||
/// while right-clicking a default `LeftButtonUp` button does not.
|
||||
/// `CButton::OnMouseDown` shows the pushed texture when the button is registered for that mouse
|
||||
/// button, up or down (`0x77924b`: `[this+0x330] & (m | m << 8)`), whether or not a click fires.
|
||||
#[test]
|
||||
fn any_registered_mouse_button_shows_the_pushed_texture() {
|
||||
let mut s = script();
|
||||
@@ -290,7 +252,6 @@ fn any_registered_mouse_button_shows_the_pushed_texture() {
|
||||
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
|
||||
};
|
||||
|
||||
// The bar slot lights under EITHER button.
|
||||
for button in ["LeftButton", "RightButton"] {
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, button, true);
|
||||
@@ -307,7 +268,6 @@ fn any_registered_mouse_button_shows_the_pushed_texture() {
|
||||
assert!(shows(&s, "Interface\\BarN.blp"), "the release restores it");
|
||||
}
|
||||
|
||||
// The plain button lights under the left only — `0x77924b` is a real gate, not a formality.
|
||||
s.mouse_move(250.0, 50.0);
|
||||
s.mouse_button(250.0, 50.0, "RightButton", true);
|
||||
assert!(
|
||||
@@ -321,9 +281,8 @@ fn any_registered_mouse_button_shows_the_pushed_texture() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `SetButtonState`/`GetButtonState` (`0x780270`/`0x780180`) — the scripted press state the ref's
|
||||
/// `ActionButtonDown/Up` keybind pair drives: PUSHED shows the pushed texture with no mouse
|
||||
/// involved, NORMAL restores, unknown states error, and a disabled button answers DISABLED.
|
||||
/// `SetButtonState`/`GetButtonState` (`0x780270`/`0x780180`), the press state stock
|
||||
/// `ActionButtonDown`/`Up` drive; an unknown state raises, a disabled button reads DISABLED.
|
||||
#[test]
|
||||
fn set_button_state_drives_the_pushed_visual() {
|
||||
let mut s = script();
|
||||
@@ -356,9 +315,7 @@ fn set_button_state_drives_the_pushed_visual() {
|
||||
assert!(shows(&s, "Interface\\P.blp") && !shows(&s, "Interface\\N.blp"));
|
||||
s.run(r#"PushBtn:SetButtonState("NORMAL")"#).unwrap();
|
||||
assert!(shows(&s, "Interface\\N.blp") && !shows(&s, "Interface\\P.blp"));
|
||||
// Unknown state: a runtime error, not a silent no-op.
|
||||
assert!(s.run(r#"PushBtn:SetButtonState("SIDEWAYS")"#).is_err());
|
||||
// Disabled wins the read (Enable/Disable owns that state, not SetButtonState).
|
||||
s.run("PushBtn:Disable()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return PushBtn:GetButtonState()").unwrap(),
|
||||
@@ -385,20 +342,17 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Physical click: the checked state flips BEFORE OnClick (the documented contract).
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
|
||||
// `seen_checked` is whatever `GetChecked()` handed the handler — the NUMBER 1 (1830).
|
||||
// `GetChecked()` answers the number 1, not true.
|
||||
assert!(s.eval::<bool>("return seen_checked == 1").unwrap());
|
||||
assert!(s.eval::<bool>("return Toggler:GetChecked()").unwrap());
|
||||
|
||||
// Programmatic Click() rides the same path: toggles back off.
|
||||
s.run("Toggler:Click()").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
assert!(!s.eval::<bool>("return Toggler:GetChecked()").unwrap());
|
||||
|
||||
// Disabled: the click is swallowed — no OnClick, no toggle.
|
||||
s.run("Toggler:Disable()").unwrap();
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
@@ -406,7 +360,6 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
assert!(!s.eval::<bool>("return Toggler:GetChecked()").unwrap());
|
||||
|
||||
// SetChecked takes 1/true/nil (1.12 and Era forms).
|
||||
s.run("Toggler:SetChecked(1)").unwrap();
|
||||
assert!(s.eval::<bool>("return Toggler:GetChecked()").unwrap());
|
||||
s.run("Toggler:SetChecked(nil)").unwrap();
|
||||
@@ -429,8 +382,7 @@ fn default_registration_is_left_click_only_right_click_reaches_nothing() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// A right-click (press+release, same frame) reaches OnMouseDown/OnMouseUp but never OnClick —
|
||||
// the client's own default registered-click set is {"LeftButtonUp"} only.
|
||||
// The 1.12 client's default registered-click set is {"LeftButtonUp"} alone.
|
||||
s.mouse_button(50.0, 50.0, "RightButton", true);
|
||||
s.mouse_button(50.0, 50.0, "RightButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 0);
|
||||
@@ -463,7 +415,6 @@ fn register_for_clicks_grows_right_click_and_carries_the_button_name() {
|
||||
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "RightButton");
|
||||
assert_eq!(s.eval::<String>("return arg1_btn").unwrap(), "RightButton");
|
||||
|
||||
// RegisterForClicks replaces the set with its whole vararg list — LeftButtonUp still fires.
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
@@ -489,14 +440,12 @@ fn down_registration_fires_on_press_and_toggles_checked_once() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// The press alone fires OnClick (down=true) and toggles Checked exactly once.
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
|
||||
assert!(s.eval::<bool>("return seen_down == true").unwrap());
|
||||
assert!(s.eval::<bool>("return QuickSell:GetChecked()").unwrap());
|
||||
|
||||
// The release does not fire again — "LeftButtonUp" is no longer in the registered set
|
||||
// (RegisterForClicks replaced it, it did not add to it).
|
||||
// `RegisterForClicks` replaced the set, so "LeftButtonUp" no longer fires.
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
@@ -526,24 +475,20 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() {
|
||||
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
|
||||
.expect(path)
|
||||
};
|
||||
// The highlight carries the ADD contract (the client's SetHighlightTexture default).
|
||||
// The 1.12 client's `SetHighlightTexture` defaults the texture to ADD.
|
||||
assert!(matches!(
|
||||
&find("Interface\\Hi.blp").content,
|
||||
QuadContent::Texture { additive: true, .. }
|
||||
));
|
||||
// A fresh state texture gets the creation-path implicit SetAllPoints (decision 1310 — the
|
||||
// reference's string setters anchor a freshly built texture to the button outright), whose
|
||||
// two corners pin all four edges: the later 64px size is structurally unread and the ring
|
||||
// FILLS the 36px button.
|
||||
// The reference's string setter anchors a texture it builds with `SetAllPoints`, so the later
|
||||
// 64px size is unread and the ring fills the 36px button.
|
||||
let r = find("Interface\\Ring.blp").rect.unwrap();
|
||||
assert_eq!(
|
||||
(r.left, r.right, r.bottom, r.top),
|
||||
(100.0, 136.0, 100.0, 136.0)
|
||||
);
|
||||
// The real quickslot-overhang idiom is an ANCHOR, not an anchorless size (ActionButton's
|
||||
// 66×66 UI-Quickslot2 authors `<Anchor point="CENTER">`): one CENTER point replaces only its
|
||||
// own slot, but clearing first leaves the single anchor + the 64px size → the centered
|
||||
// overhang, 100..136 → 86..150.
|
||||
// The quickslot overhang is an anchor (`ActionButtonTemplate.xml`'s 66x66 UI-Quickslot2 is
|
||||
// `<Anchor point="CENTER">`): cleared, then one CENTER point and the 64px size give 86..150.
|
||||
s.run(
|
||||
r#"
|
||||
local n = AddBtn:GetNormalTexture()
|
||||
@@ -563,7 +508,6 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() {
|
||||
(r.left, r.right, r.bottom, r.top),
|
||||
(86.0, 150.0, 86.0, 150.0)
|
||||
);
|
||||
// SetBlendMode overrides back to straight alpha.
|
||||
s.run(r#"AddBtn:GetHighlightTexture():SetBlendMode("BLEND")"#)
|
||||
.unwrap();
|
||||
let quads = s.extract();
|
||||
@@ -581,10 +525,8 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The per-state label fonts (`SetTextFontObject`/`SetDisabledFontObject`, XML `<NormalFont>` etc.):
|
||||
/// the ButtonText re-points to the CURRENT state's font object at extract — gold while enabled,
|
||||
/// gray after Disable() — with no Lua repaint (the client's UIPanelButtonTemplate label behavior).
|
||||
/// An explicit SetTextColor survives the re-point (the client's explicitly-set mask).
|
||||
/// The label wears the current state's font object (`SetTextFontObject`, `SetDisabledFontObject`)
|
||||
/// with no Lua repaint; its own `SetTextColor` clears the colour's inherit bit and survives that.
|
||||
#[test]
|
||||
fn button_label_repaints_by_state_font_object() {
|
||||
let mut s = script();
|
||||
@@ -649,30 +591,16 @@ fn button_label_repaints_by_state_font_object() {
|
||||
"re-enabled: gold again"
|
||||
);
|
||||
|
||||
// An explicit SetTextColor wins over the state font's color (explicitly-set mask).
|
||||
s.run("b:GetFontString():SetTextColor(0.1, 0.2, 0.3)")
|
||||
.unwrap();
|
||||
let c = label_color(&mut s).expect("colored");
|
||||
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// **The highlighted label is its own font instance — and `LockHighlight` enters it.** The two
|
||||
/// halves of the recipe-list look the director asked for: a row turns white under the cursor, and
|
||||
/// the SELECTED row stays white with the cursor elsewhere.
|
||||
///
|
||||
/// Both are one mechanism in the reference. `ClassTrainerSkillButtonTemplate` (the base under
|
||||
/// TradeSkill's, Craft's and the class trainer's rows) declares
|
||||
/// `<HighlightFont inherits="GameFontHighlight">`, and each window paints its rows by difficulty
|
||||
/// with `skillButton:SetTextColor(...)` — orange, yellow, green, gray. If a `SetTextColor` reached
|
||||
/// the highlighted state, hovering a row would leave it orange; it does not, because the highlight
|
||||
/// instance is a different font instance and the normal one's colour cannot reach it. That is the
|
||||
/// first assertion below.
|
||||
///
|
||||
/// The second is `LockHighlight`. `TradeSkillFrame_Update` blanks a recipe row's highlight texture
|
||||
/// (`getglobal("TradeSkillSkill"..i.."Highlight"):SetTexture("")`, Blizzard_TradeSkillUI.lua l.131)
|
||||
/// and *then* locks the row it selected (l.144) — with no texture left to pin, the lock can only be
|
||||
/// reaching the label. Craft (Blizzard_CraftUI.lua l.234) and the trainer
|
||||
/// (Blizzard_TrainerUI.lua l.183) do the same.
|
||||
/// The highlight label is its own font instance, put in force by a hover or `LockHighlight`, so the
|
||||
/// normal instance's `SetTextColor` cannot reach it: `ClassTrainerSkillButtonTemplate`'s recipe
|
||||
/// rows (`<HighlightFont inherits="GameFontHighlight">`) turn white, and
|
||||
/// `Blizzard_TradeSkillUI.lua:144` locks the selected row after blanking its highlight texture.
|
||||
#[test]
|
||||
fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() {
|
||||
let mut s = script();
|
||||
@@ -737,7 +665,6 @@ fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() {
|
||||
"hovered: the HIGHLIGHT instance is in force, and the normal colour cannot reach it"
|
||||
);
|
||||
|
||||
// Cursor away again, but the row is the selected one.
|
||||
s.resolve();
|
||||
s.mouse_move(400.0, 300.0);
|
||||
s.run("b:LockHighlight()").unwrap();
|
||||
@@ -753,8 +680,6 @@ fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() {
|
||||
"unlocked: back to the difficulty colour"
|
||||
);
|
||||
|
||||
// A button with no highlight instance keeps the normal one whole, hover or not — the shape
|
||||
// every plain labelled Button in the corpus has.
|
||||
s.run(r#"b:SetHighlightFontObject(nil); b:SetText("Rough Copper Vest")"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
@@ -767,14 +692,8 @@ fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`Button:GetTextWidth` / `GetTextHeight`** — the reference's own Button text-extent readers
|
||||
/// (`0x782290` / `0x782390`; present on Button, and `GetStringWidth` **absent**). Both forward to
|
||||
/// the label FontString's own extent slots, which is what this asserts: answer a host measure for
|
||||
/// the label, and the BUTTON must report it.
|
||||
///
|
||||
/// `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
|
||||
/// `button:GetTextWidth() + 40`, so a nil method took the whole dropdown down — the director could
|
||||
/// not switch characters in Bagnon at all.
|
||||
/// `Button:GetTextWidth`/`GetTextHeight` (`0x782290`/`0x782390`; Button has no `GetStringWidth`)
|
||||
/// forward to the label FontString's extent.
|
||||
#[test]
|
||||
fn a_button_reports_its_labels_extent() {
|
||||
let mut s = script();
|
||||
@@ -791,7 +710,7 @@ fn a_button_reports_its_labels_extent() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Unmeasured is 0 — the same "converges next frame" contract every other metric read has.
|
||||
// 0 until the host's measure lands, like every metric read here.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
|
||||
0.0
|
||||
@@ -814,15 +733,13 @@ fn a_button_reports_its_labels_extent() {
|
||||
14.0
|
||||
);
|
||||
|
||||
// A Button with no label at all answers 0 rather than raising: the reference dereferences a
|
||||
// FontString pointer (`+0x338`) here that a bare CreateFrame("Button") leaves null, and what it
|
||||
// does then is not byte-read — so this takes the harmless number instead of guessing a crash.
|
||||
// A label-less Button answers 0; the reference reads the label pointer (`+0x338`), null here,
|
||||
// and what it does then is untraced.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return LabellessBtn:GetTextWidth()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// …and it is a BUTTON method: a plain Frame must not have grown one.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return CreateFrame("Frame").GetTextWidth == nil"#)
|
||||
.unwrap(),
|
||||
@@ -830,10 +747,8 @@ fn a_button_reports_its_labels_extent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`Button:SetFontString` adopts aggressively, and it RAISES** — `0x780a60`.
|
||||
///
|
||||
/// The first cut of this binding was a lenient no-op on a bad argument and a bare pointer swap on
|
||||
/// a good one. Every line below is a clause of that binding that the plausible reading got wrong.
|
||||
/// `Button:SetFontString` (`0x780a60`) re-parents the string, adopts it as the label and destroys
|
||||
/// the old one, and raises on anything but a FontString.
|
||||
#[test]
|
||||
fn set_font_string_adopts_the_label_and_raises_on_anything_else() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -848,28 +763,20 @@ fn set_font_string_adopts_the_label_and_raises_on_anything_else() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Adoption binds the string as the label the whole rest of the surface reads.
|
||||
s.run("b:SetFontString(fs)").unwrap();
|
||||
assert!(s.eval::<bool>("return b:GetFontString() == fs").unwrap());
|
||||
s.run("b:SetText(\"Okay\")").unwrap();
|
||||
assert_eq!(s.eval::<String>("return fs:GetText()").unwrap(), "Okay");
|
||||
assert_eq!(s.eval::<String>("return b:GetText()").unwrap(), "Okay");
|
||||
|
||||
// Re-parented — always, whatever it was parented to before.
|
||||
assert!(s.eval::<bool>("return fs:GetParent() == b").unwrap());
|
||||
// The draw layer is forced to ARTWORK on the same call (`0x77fd10(parent, 2, 1)`), and that
|
||||
// half is NOT asserted here for a stated reason: `GetDrawLayer` is one of the client's nine
|
||||
// per-leaf verbs we deliberately do not install (`script::REGION_LEAF_SHARED`'s own note —
|
||||
// "absent is absent"), so there is no Lua way to read it back yet. The binding does it; when
|
||||
// that verb lands, this is where it gets pinned.
|
||||
// It also forces the draw layer to ARTWORK (`0x77fd10(parent, 2, 1)`); not asserted here.
|
||||
|
||||
// Adopting the SAME string again is a total no-op — the first compare in the client, and the
|
||||
// one path that must not run the destroy below.
|
||||
// The same string again is a no-op, the client's first compare, so it is not destroyed.
|
||||
s.run("b:SetFontString(fs)").unwrap();
|
||||
assert!(s.eval::<bool>("return b:GetFontString() == fs").unwrap());
|
||||
assert_eq!(s.eval::<String>("return fs:GetText()").unwrap(), "Okay");
|
||||
|
||||
// A SECOND adoption DESTROYS the first label rather than orphaning it.
|
||||
s.run("fs2 = b:CreateFontString(\"MyLabel2\", \"OVERLAY\") b:SetFontString(fs2)")
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return b:GetFontString() == fs2").unwrap());
|
||||
@@ -879,8 +786,7 @@ fn set_font_string_adopts_the_label_and_raises_on_anything_else() {
|
||||
"the replaced label must be destroyed, not left live: {err}"
|
||||
);
|
||||
|
||||
// Three distinct raises, all naming the BUTTON — never a silent no-op, and `nil` cannot clear
|
||||
// the label from Lua at all.
|
||||
// Three distinct raises, each naming the button; `nil` cannot clear the label from Lua.
|
||||
for (call, want) in [
|
||||
("b:SetFontString()", "Usage: Btn:SetFontString(fontstring)"),
|
||||
(
|
||||
@@ -904,19 +810,16 @@ fn set_font_string_adopts_the_label_and_raises_on_anything_else() {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "`{call}` → {err}");
|
||||
}
|
||||
// A TEXTURE is the near-miss the type gate exists for: it is a Region, and it still fails.
|
||||
// A Texture is a Region and still fails the FontString type gate.
|
||||
s.run("tex = b:CreateTexture(nil, \"ARTWORK\")").unwrap();
|
||||
let err = s.run("b:SetFontString(tex)").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Wrong object type, expected fontstring"),
|
||||
"{err}"
|
||||
);
|
||||
// …and none of that changed the label.
|
||||
assert!(s.eval::<bool>("return b:GetFontString() == fs2").unwrap());
|
||||
}
|
||||
|
||||
/// Adoption anchors the string **only when it has none of its own** — and leaves an already
|
||||
/// anchored one exactly where the caller put it.
|
||||
#[test]
|
||||
fn set_font_string_anchors_only_an_unanchored_label() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -946,7 +849,7 @@ fn set_font_string_anchors_only_an_unanchored_label() {
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return placed:GetNumPoints()").unwrap(), 1);
|
||||
|
||||
// The unanchored one gets the justify-derived point, to the matching point on the button.
|
||||
// With no normal-font justify, the unanchored one is anchored CENTER to CENTER.
|
||||
s.run("b:SetFontString(bare)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return bare:GetNumPoints()").unwrap(), 1);
|
||||
let (p, _rel, rp, x, y): (String, mlua::Value, String, f64, f64) =
|
||||
@@ -957,15 +860,10 @@ fn set_font_string_anchors_only_an_unanchored_label() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A label the button makes for itself (`SetText`'s lazy creator `0x778dc0`) or adopts
|
||||
/// (`SetFontString`) is anchored by the BUTTON's normal font justify, not by the fresh string's
|
||||
/// own ctor-default word: both funnel through `CSimpleButton::SetFontString 0x778d20`, whose
|
||||
/// unanchored-label leg reads `[button+0x390]` — the NORMAL embedded `CSimpleFont`'s justify —
|
||||
/// LEFT→LEFT / RIGHT→RIGHT / else CENTER, then links the label to that font (`0x779810`).
|
||||
/// Decision 1996.
|
||||
///
|
||||
/// `<NormalFont justifyH=>` is what writes that word in FrameXML (`UIMenuButtonTemplate`,
|
||||
/// `MailFrame.xml`'s RIGHT money button), so this is the chat menu's "Macro/macro" in miniature.
|
||||
/// A label made lazily (`SetText`, `0x778dc0`) or adopted goes through
|
||||
/// `CSimpleButton::SetFontString 0x778d20`, which anchors it by the button's normal font justify
|
||||
/// (`[button+0x390]`: LEFT, RIGHT, else CENTER), not the string's own, then links it to that font
|
||||
/// (`0x779810`). FrameXML writes that justify with `<NormalFont justifyH=>`.
|
||||
#[test]
|
||||
fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
let mut s = script();
|
||||
@@ -1006,8 +904,8 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// `<NormalFont>` alone creates no label — the reference's LoadXML never touches `+0x338` on
|
||||
// that leg — so `GetFontString()` is nil until something sets text.
|
||||
// `<NormalFont>` alone creates no label (the reference's LoadXML leaves `+0x338` null), so
|
||||
// `GetFontString()` is nil until text is set.
|
||||
assert!(s
|
||||
.eval::<bool>("return left:GetFontString() == nil")
|
||||
.unwrap());
|
||||
@@ -1042,7 +940,6 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
1
|
||||
);
|
||||
}
|
||||
// The link is applied on the spot: the label's own word and object answer the normal font's.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return left:GetFontString():GetJustifyH()")
|
||||
.unwrap(),
|
||||
@@ -1052,7 +949,6 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
.eval::<bool>("return left:GetFontString():GetFontObject() == ProbeFont")
|
||||
.unwrap());
|
||||
|
||||
// Adoption reads the same word: an unanchored string handed to the LEFT button seats LEFT.
|
||||
s.run(
|
||||
r#"
|
||||
bare = left:CreateFontString(nil, "OVERLAY")
|
||||
@@ -1067,24 +963,14 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
|
||||
"SetFontString anchors by the normal font's justify too"
|
||||
);
|
||||
|
||||
// A later `SetTextFontObject` re-links the normal font but does NOT re-anchor — the anchor
|
||||
// was decided at adoption and is an ordinary anchor from then on.
|
||||
// A later `SetTextFontObject` re-links the normal font but does not re-anchor.
|
||||
s.run("left:SetTextFontObject(ProbeFontRight)").unwrap();
|
||||
assert_eq!(point(&s, "left"), ("LEFT".into(), "LEFT".into(), 0.0, 0.0));
|
||||
}
|
||||
|
||||
/// **A label that set its own face keeps it — the severance mask covers every axis, not three of
|
||||
/// six** (decision 2112).
|
||||
///
|
||||
/// `font_explicit` is the client's explicitly-set mask (`FONTINSTANCE+0x38`): an axis a widget
|
||||
/// writes for *itself* severs inheritance from the font instance it reads, and is never restored
|
||||
/// (see the `button_font` block in `script::extract`). `font::repaint` honours it on all seven
|
||||
/// axes. The extract's per-state re-point honoured it on shadow, colour and both justifies — and
|
||||
/// not on **face, height or outline**: the face and height read `fo.x.or(data.x)`, which makes the
|
||||
/// object outrank an explicit `SetFont`, and the outline was written unconditionally. A
|
||||
/// `<ButtonText>` that called `SetFont(path, h, "OUTLINE")` for itself therefore had all three
|
||||
/// silently put back from the button's font object on the very next extract — every frame, so no
|
||||
/// Lua could win the race.
|
||||
/// A label's own `SetFont` clears its font inherit bit (the reference's `FONTINSTANCE+0x2c`
|
||||
/// inherit mask, a FontString's `+0xd4`), and nothing sets it back, so its face, height and
|
||||
/// outline outlast every state font re-point while its other axes still inherit.
|
||||
#[test]
|
||||
fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
|
||||
let mut s = script();
|
||||
@@ -1134,7 +1020,6 @@ fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
|
||||
),
|
||||
"the label's own SetFont severs face, height AND outline from the state font object"
|
||||
);
|
||||
// …and it survives a state change, which is what re-runs the re-point.
|
||||
s.run("b:Disable()").unwrap();
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
@@ -1145,7 +1030,6 @@ fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
|
||||
),
|
||||
"…and a disable re-points the instance without restoring what the label severed"
|
||||
);
|
||||
// The axes the label did NOT set still follow the object: the colour is the template's gold.
|
||||
s.resolve();
|
||||
let color = s
|
||||
.extract()
|
||||
@@ -1166,21 +1050,9 @@ fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A state-texture setter takes an OBJECT and takes nil**, not only a path — the reference's
|
||||
/// `0x781970` forks on `lua_type(L, 2)` into four legs and benilla honoured one of them
|
||||
/// (decision 2124).
|
||||
///
|
||||
/// Both missing legs are silent no-ops rather than errors, which is why nothing caught them:
|
||||
///
|
||||
/// * **the object leg** (`0x781b0b` → `0x778fd0`) installs the handed Texture *into the slot*.
|
||||
/// Three corpus addons build a highlight this way and every one of them drew nothing —
|
||||
/// `Bongos/bar.lua:64-71`, `_Nameplates/_Nameplates.lua:217-222`,
|
||||
/// `Quiver/Quiver.bundle.lua:8949-8953`. The idiom below is Bongos', verbatim in shape.
|
||||
/// * **the nil leg** (`0x781b5a` → `0x778fd0` with 0) clears the slot and dtors what was in it.
|
||||
/// `TheoryCraft/TheoryCraftUI.lua:215-217` strips a talent-rank button with three of these.
|
||||
/// Clearing the *pointer* alone would be worse than the no-op: `region_visible` draws any region
|
||||
/// that is not one of the button's slots unconditionally, so a merely unhooked state texture
|
||||
/// would appear in every state instead of none — which is what this test's last assertion pins.
|
||||
/// A state-texture setter forks on `lua_type(L, 2)` (`0x781970`): a Texture object is installed
|
||||
/// into the slot (`0x781b0b` → `0x778fd0`), and nil clears the slot and destroys its texture
|
||||
/// (`0x781b5a`), which merely unhooked would draw in every state.
|
||||
#[test]
|
||||
fn a_state_texture_slot_takes_an_object_and_a_nil() {
|
||||
let mut s = script();
|
||||
@@ -1210,8 +1082,6 @@ fn a_state_texture_slot_takes_an_object_and_a_nil() {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// The handed object IS the slot now: it draws on hover and only on hover, exactly as a
|
||||
// path-loaded highlight does.
|
||||
assert_eq!(drawn(&s), vec!["Interface\\N.blp".to_string()]);
|
||||
assert!(
|
||||
s.eval::<bool>("return b:GetHighlightTexture() == hl")
|
||||
@@ -1228,7 +1098,6 @@ fn a_state_texture_slot_takes_an_object_and_a_nil() {
|
||||
]
|
||||
);
|
||||
|
||||
// nil clears — and the cleared art is gone from every state, not merely unhooked.
|
||||
s.run("b:SetHighlightTexture(nil) b:SetNormalTexture(nil)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
@@ -1244,14 +1113,9 @@ fn a_state_texture_slot_takes_an_object_and_a_nil() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The unlocked scripted push is released by the next mouse release — Tablet-2.0's rows**
|
||||
/// (decision 2134).
|
||||
///
|
||||
/// `SetButtonState(state[, lock])` writes `[+0x32c]` unconditionally, and the mouse-up edge
|
||||
/// `0x7793de` un-presses whenever `locked == 0`. Tablet-2.0 (`Tablet-2.0.lua:1645`) pushes a row it
|
||||
/// finds `clicked` and calls `SetButtonState("NORMAL")` *nowhere in the library* — it relies on
|
||||
/// exactly this. Ours kept a scripted push until Lua cleared it, so a Questie/FuBar/oRA2 row stayed
|
||||
/// depressed for the rest of the session.
|
||||
/// `SetButtonState(state[, lock])` always writes the lock (`[+0x32c]`), and the mouse-up edge
|
||||
/// (`0x7793de`) un-presses an unlocked button; `Tablet-2.0.lua:1645` pushes a row and never
|
||||
/// releases it itself.
|
||||
#[test]
|
||||
fn an_unlocked_scripted_push_is_released_by_the_next_mouse_release() {
|
||||
let mut s = script();
|
||||
@@ -1273,7 +1137,6 @@ fn an_unlocked_scripted_push_is_released_by_the_next_mouse_release() {
|
||||
"PUSHED"
|
||||
);
|
||||
|
||||
// A press and release over the row: the release edge finds it PUSHED and unlocked.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
@@ -1286,13 +1149,8 @@ fn an_unlocked_scripted_push_is_released_by_the_next_mouse_release() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`lock` pins the state against the mouse — the micro buttons** (`0x780270`'s third index,
|
||||
/// `GetBoolOrDefault` with default 0).
|
||||
///
|
||||
/// `MainMenuBarMicroButtons.lua` calls `SetButtonState("PUSHED", 1)` when its panel opens, and the
|
||||
/// button must stay depressed through every press and release until the panel closes. The same
|
||||
/// flag pins a NORMAL button *against* being pushed, which is the half a "sticky push" model
|
||||
/// cannot express at all.
|
||||
/// `SetButtonState`'s third argument (`0x780270`, default 0) locks the state against the mouse,
|
||||
/// PUSHED or NORMAL; `MainMenuBarMicroButtons.lua:22` holds a micro button PUSHED this way.
|
||||
#[test]
|
||||
fn a_locked_state_ignores_the_mouse_and_enable_disable_clears_the_lock() {
|
||||
let mut s = script();
|
||||
@@ -1330,8 +1188,7 @@ fn a_locked_state_ignores_the_mouse_and_enable_disable_clears_the_lock() {
|
||||
);
|
||||
s.mouse_button(250.0, 50.0, "LeftButton", false);
|
||||
|
||||
// `0x779160` passes `locked = 0` on both arms, so Enable/Disable UNLOCK. The panel closing
|
||||
// with a plain `SetButtonState("NORMAL")` (flag defaulting to 0) does the same.
|
||||
// `0x779160` passes `locked = 0` on both arms, so Enable and Disable unlock.
|
||||
s.run("MicroButton:Disable() MicroButton:Enable()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MicroButton:GetButtonState()")
|
||||
@@ -1351,14 +1208,9 @@ fn a_locked_state_ignores_the_mouse_and_enable_disable_clears_the_lock() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Walking off a held button does NOT un-press it, and walking back on does not re-press it.**
|
||||
///
|
||||
/// The correction decision 2134 is built on. Our old model derived the press as
|
||||
/// `(held && hovered) || pushed_state` and re-evaluated it whenever the hover moved; the
|
||||
/// reference's enter and leave notifies (`0x779490`/`0x7794e0`) read `[+0x328]` only as a DISABLED
|
||||
/// guard and **never write it** — proved by a census of every store to the field image-wide.
|
||||
/// The only thing that un-presses a held button before its release is the drag-threshold crossing,
|
||||
/// and only for a frame that registered for drag.
|
||||
/// The enter and leave notifies (`0x779490`/`0x7794e0`) read the button state (`[+0x328]`) only as
|
||||
/// a DISABLED guard and never write it; before the release, only a drag-threshold crossing on a
|
||||
/// drag-registered frame un-presses a held button.
|
||||
#[test]
|
||||
fn the_hover_is_not_an_input_to_the_press_state() {
|
||||
let mut s = script();
|
||||
@@ -1383,7 +1235,6 @@ fn the_hover_is_not_an_input_to_the_press_state() {
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert!(shows(&s, "Interface\\HeldP.blp"), "the press pushed it");
|
||||
|
||||
// Walk the cursor right off it, still holding.
|
||||
s.mouse_move(500.0, 400.0);
|
||||
assert!(
|
||||
shows(&s, "Interface\\HeldP.blp"),
|
||||
@@ -1395,7 +1246,7 @@ fn the_hover_is_not_an_input_to_the_press_state() {
|
||||
"PUSHED"
|
||||
);
|
||||
|
||||
// The release, off the button, still un-presses it — `0x7792d0` runs no hit test of its own.
|
||||
// The release un-presses it off the button too: `0x7792d0` runs no hit test.
|
||||
s.mouse_button(500.0, 400.0, "LeftButton", false);
|
||||
assert!(
|
||||
shows(&s, "Interface\\HeldN.blp"),
|
||||
@@ -1404,10 +1255,8 @@ fn the_hover_is_not_an_input_to_the_press_state() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A button hidden while held comes back NORMAL** — `CSimpleButton`'s hide override
|
||||
/// (`+0x34` = `0x7791e0`), which un-presses and then tail-jumps the base notify so `<OnHide>`
|
||||
/// still fires. It hangs off the visibility transition, not off the hover, so it reaches a button
|
||||
/// hidden nowhere near the cursor too.
|
||||
/// `CSimpleButton`'s hide override (`+0x34` = `0x7791e0`) un-presses the button, then runs the
|
||||
/// base notify so `<OnHide>` still fires.
|
||||
#[test]
|
||||
fn hiding_a_held_button_un_presses_it() {
|
||||
let mut s = script();
|
||||
@@ -1438,14 +1287,7 @@ fn hiding_a_held_button_un_presses_it() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `Button:GetTextColor()` — **FOUR** values, r/g/b/a (`0x781100`, table `0x879d00`, argc 1,
|
||||
/// arity 4, kinds `(number,number,number,number)`). Three is the plausible wrong answer, and the
|
||||
/// shapes table flags the name `name-not-unique` because seven tables register it — this asserts
|
||||
/// the BUTTON one, on a Button, and its two inheritance legs.
|
||||
///
|
||||
/// Completeness rather than a live break: no corpus site has a Button receiver today (every
|
||||
/// measured `GetTextColor` is on a FontString or a font object). It is here because the reference
|
||||
/// registers it, so the widget shape gate can cover it from now on.
|
||||
/// `Button:GetTextColor()` returns four values, r, g, b, a (`0x781100`, table `0x879d00`).
|
||||
#[test]
|
||||
fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
let s = script();
|
||||
@@ -1479,8 +1321,7 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
.unwrap();
|
||||
assert_eq!(kinds, "number,number,number,number");
|
||||
|
||||
// A button with nothing set anywhere is the untinted white every other colour getter here
|
||||
// (`GetVertexColor`, `FontString:GetTextColor`) answers.
|
||||
// Nothing set: the untinted white the other colour getters here answer.
|
||||
let plain: Vec<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
@@ -1492,8 +1333,7 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
.collect();
|
||||
assert_eq!(plain, vec![1.0, 1.0, 1.0, 1.0]);
|
||||
|
||||
// With no local colour it reads THROUGH what the normal state inherits — the same leg
|
||||
// `Button:GetFont` takes, and the one a stock `GameMenuButtonTemplate` button relies on.
|
||||
// Unset locally, it reads through the normal state's font object, as `Button:GetFont` does.
|
||||
let themed: Vec<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
let discards = "_, ".repeat(i - 1);
|
||||
@@ -1505,7 +1345,6 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
.collect();
|
||||
assert_eq!(themed, vec![1.0, 0.82, 0.0, 1.0]);
|
||||
|
||||
// A local SetTextColor wins, alpha included, and round-trips.
|
||||
s.run("ThemedBtn:SetTextColor(0.1, 0.2, 0.3, 0.4)").unwrap();
|
||||
let set: Vec<f32> = (1..=4)
|
||||
.map(|i| {
|
||||
@@ -1518,7 +1357,7 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
|
||||
.collect();
|
||||
assert_eq!(set, vec![0.1, 0.2, 0.3, 0.4]);
|
||||
|
||||
// A CheckButton reaches it through Button's table, as it does the rest of the trio.
|
||||
// A CheckButton reaches it through Button's table.
|
||||
s.run(r#"CreateFrame("CheckButton", "ChkColorBtn")"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.arity("ChkColorBtn:GetTextColor()").unwrap(), 4);
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
//! `GetChannelName` — the joined-channel lookup ([`crate::script::channel`]).
|
||||
//!
|
||||
//! The verb is small; the contract around it is not, and every test here is named after one claim.
|
||||
//! The load-bearing one is [`a_channel_that_is_not_joined_answers_the_number_zero_never_nil`]: the
|
||||
//! reference's own callers and the corpus both compare the first return numerically, so a nil there
|
||||
//! does not degrade — it raises, at four call sites that work today.
|
||||
//! The joined-channel verbs `GetChannelName` and `GetChannelList` ([`crate::script::channel`])
|
||||
//! and the guild-recruitment latch.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Two channels joined in order, the way `ui_chat::feed` mirrors them after the server's
|
||||
/// YOU_JOINED. Join ORDER is the whole numbering law, so the fixture is deliberately not sorted.
|
||||
/// Two channels in join order, which is the numbering; deliberately not sorted.
|
||||
fn joined() -> crate::script::UiScript {
|
||||
let mut s = script();
|
||||
s.set_joined_channels(vec![Some("World".into()), Some("Trade - City".into())]);
|
||||
s
|
||||
}
|
||||
|
||||
/// **A joined channel answers its 1-based slot, its name, and an instanceID.** The slot is the
|
||||
/// position in JOIN order — not a DBC id, not an alphabetical rank.
|
||||
/// The slot is the 1-based position in join order, not a DBC id or an alphabetical rank.
|
||||
#[test]
|
||||
fn a_joined_channel_answers_its_slot_name_and_instance() {
|
||||
let s = joined();
|
||||
@@ -29,9 +23,7 @@ fn a_joined_channel_answers_its_slot_name_and_instance() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The name form resolves, case-insensitively, and yields the same slot as the index form.**
|
||||
/// Half the corpus sites look up by name (`GetChannelName("world")`, `GetChannelName("Trade -
|
||||
/// City")`), and `_LazyPig` passes `"world"` for a channel the server names `"World"`.
|
||||
/// Addons look up by name in any case: `_LazyPig` passes `"world"` for the server's `"World"`.
|
||||
#[test]
|
||||
fn the_name_form_resolves_case_insensitively_to_the_same_slot() {
|
||||
let s = joined();
|
||||
@@ -49,16 +41,7 @@ fn the_name_form_resolves_case_insensitively_to_the_same_slot() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Not joined ⇒ the NUMBER 0. Never nil.** This is the trap, and it is the only way this verb can
|
||||
/// break working code rather than merely fail to help it.
|
||||
///
|
||||
/// Verified from both sides. The reference compares it numerically at `ChatFrame.lua:2114`
|
||||
/// (`if ( channelNum > 0 )`) and `l.2232` (`if ( channelNum <= 0 ) then return end`); so does the
|
||||
/// corpus at `_LazyPig/LazyPig.lua:1996` (`if id > 0 then`). A nil first return turns every one of
|
||||
/// those into "attempt to compare nil with number" — a loud failure in code that works today.
|
||||
///
|
||||
/// Asserted by DOING the comparison, not by inspecting the type, because the comparison is the
|
||||
/// thing that must not raise.
|
||||
/// Stock callers compare the first return numerically (`ChatFrame.lua:2114`, `:2232`).
|
||||
#[test]
|
||||
fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
|
||||
let s = joined();
|
||||
@@ -66,7 +49,6 @@ fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
|
||||
let id: i64 = s.eval("return GetChannelName('NoSuchChannel')").unwrap();
|
||||
assert_eq!(id, 0);
|
||||
|
||||
// The reference's own guard shape, run for real: it must evaluate, not raise.
|
||||
let guard: bool = s
|
||||
.eval("local id = GetChannelName('NoSuchChannel') return id > 0")
|
||||
.unwrap();
|
||||
@@ -89,9 +71,7 @@ fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A numeric STRING resolves as a number**, because `ChatFrame.lua:2113` hands one straight in:
|
||||
/// it `gsub`s `/1` down to `"1"` and calls `GetChannelName(channel)`. On the real client Lua's own
|
||||
/// coercion makes that work; ours must not treat it as a channel literally named "1".
|
||||
/// `ChatFrame.lua:2113` passes the `"1"` it cuts out of `/1`, which Lua coerces to a number.
|
||||
#[test]
|
||||
fn a_numeric_string_resolves_as_an_index_not_as_a_name() {
|
||||
let s = joined();
|
||||
@@ -99,9 +79,7 @@ fn a_numeric_string_resolves_as_an_index_not_as_a_name() {
|
||||
assert_eq!(id, 2, "the `/2` slash-command path depends on this");
|
||||
}
|
||||
|
||||
/// **Empty until the server confirms a join.** `ui_chat::feed` appends on the server's YOU_JOINED
|
||||
/// notice, never on the request, so a session that has asked and not been answered has no slots —
|
||||
/// and the verb must still answer 0 rather than raise on an empty list.
|
||||
/// `ui_chat::feed` appends on the server's YOU_JOINED notice, never on the request.
|
||||
#[test]
|
||||
fn nothing_is_joined_before_the_server_confirms_it() {
|
||||
let s = script();
|
||||
@@ -109,30 +87,22 @@ fn nothing_is_joined_before_the_server_confirms_it() {
|
||||
assert_eq!(id, 0);
|
||||
}
|
||||
|
||||
/// **`GetChannelList()` is a FLAT vararg of (slot, name) pairs in join order** — not a table, and
|
||||
/// not name-first.
|
||||
///
|
||||
/// No single reference address pins the shape; two independent consumers do, and the
|
||||
/// test asserts what each of them relies on. The reference's `FCFDropDown_LoadChannels` walks
|
||||
/// `for i=1, arg.n, 2` and reads `arg[i+1]` as the name, so the pair order is (slot, name) and the
|
||||
/// stride is 2. `ChatLog.lua:424` packs it with `{ GetChannelList() }` and tests
|
||||
/// `type(value) == "number"` to spot an id, so the values must interleave rather than nest.
|
||||
/// The shape its consumers pin: `FCFDropDown_LoadChannels` walks `for i=1, arg.n, 2` reading
|
||||
/// `arg[i+1]` as the name, and `ChatLog.lua:424` packs it and spots an id by `type == "number"`.
|
||||
#[test]
|
||||
fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() {
|
||||
let s = joined();
|
||||
|
||||
// Exactly two pairs for two joined channels — the arity `FCFDropDown_LoadChannels` steps over.
|
||||
// Two pairs for two joined channels.
|
||||
assert_eq!(s.arity("GetChannelList()").unwrap(), 4);
|
||||
|
||||
// Pair order and join order, both at once: slot 1 is the FIRST joined, not the alphabetical
|
||||
// first ("Trade - City" would sort ahead of "World").
|
||||
// Slot 1 is the first joined; alphabetically "Trade - City" would come first.
|
||||
let (s1, n1, s2, n2) = s
|
||||
.eval::<(i64, String, i64, String)>("return GetChannelList()")
|
||||
.unwrap();
|
||||
assert_eq!((s1, n1.as_str()), (1, "World"));
|
||||
assert_eq!((s2, n2.as_str()), (2, "Trade - City"));
|
||||
|
||||
// ChatLog's own walk: packed, the odd entries are numbers and the even ones are names.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local t = { GetChannelList() } \
|
||||
@@ -140,25 +110,16 @@ fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() {
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// The slot agrees with GetChannelName's, which is the point of sharing the numbering.
|
||||
assert!(s
|
||||
.eval::<bool>("local i = GetChannelName('Trade - City') return i == 2")
|
||||
.unwrap());
|
||||
|
||||
// No channels joined is ZERO returns, so `{ GetChannelList() }` is an empty table rather than
|
||||
// a table of nils — every consumer above already handles that shape.
|
||||
let empty = crate::script::tests::common::script();
|
||||
assert_eq!(empty.arity("GetChannelList()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// **The guild-recruitment latch boots at AUTO and round-trips as a NUMBER** (decision 2115).
|
||||
///
|
||||
/// `GetGuildRecruitmentMode 0x4a0040` is 23 bytes and one path — `fild` the int global,
|
||||
/// `lua_pushnumber`, `mov eax,1`, `ret` — so it has no nil leg at all, and
|
||||
/// `UIOptionsFrame_Load`'s `== 1` would read a nil as a silent "not auto". The boot value is 1
|
||||
/// from a `.data` initialiser (`raw 0x443608` = `01 00 00 00`), corroborated by
|
||||
/// `UIOptionsFrame_SetDefaults`'s `SetGuildRecruitmentMode(1)` and by all 33 `chat-cache.txt`
|
||||
/// files the reference client itself wrote in this repo's install.
|
||||
/// `GetGuildRecruitmentMode` (`0x4a0040`) always pushes its int global as a number; it boots at 1
|
||||
/// from a `.data` initialiser (`raw 0x443608`), the value `UIOptionsFrame_SetDefaults` sets.
|
||||
#[test]
|
||||
fn the_guild_recruitment_mode_boots_auto_and_answers_a_number() {
|
||||
let s = script();
|
||||
@@ -177,28 +138,19 @@ fn the_guild_recruitment_mode_boots_auto_and_answers_a_number() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The setter is shape A — it RAISES rather than swallowing a bad argument** (decision 2115).
|
||||
///
|
||||
/// `0x4a0060` gates on `lua_isnumber 0x6f34d0` (so a numeric STRING passes) and otherwise
|
||||
/// `luaL_error`s `Usage: SetGuildRecruitmentMode(mode)`; it then truncates toward zero through
|
||||
/// `__ftol 0x40a2b0` and range-gates `0 <= mode < 2`, raising
|
||||
/// `SetGuildRecruitmentMode: invalid mode` outside it. Most 1.12 numeric bindings swallow a nil
|
||||
/// as 0.0 — this one does not, and a client that guessed the common shape would turn an addon's
|
||||
/// own bug into silence.
|
||||
///
|
||||
/// Success pushes **0 values**, not nil.
|
||||
/// `0x4a0060` raises `Usage:` unless `lua_isnumber` (`0x6f34d0`), truncates through `__ftol`
|
||||
/// (`0x40a2b0`), and raises `invalid mode` outside `0 <= mode < 2`; success pushes no values.
|
||||
#[test]
|
||||
fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does() {
|
||||
let s = script();
|
||||
|
||||
// A numeric string is a number to `lua_isnumber`.
|
||||
s.run(r#"SetGuildRecruitmentMode("0")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// Truncation toward zero, not rounding: 1.7 is a legal 1.
|
||||
// 1.7 truncates to a legal 1.
|
||||
s.run("SetGuildRecruitmentMode(1.7)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
|
||||
@@ -221,7 +173,7 @@ fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does()
|
||||
assert!(e.to_string().contains("invalid mode"), "{bad}: {e}");
|
||||
}
|
||||
|
||||
// …and none of the refused calls moved the latch.
|
||||
// None of the refused calls moved the latch.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
|
||||
1.0
|
||||
@@ -233,10 +185,8 @@ fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does()
|
||||
);
|
||||
}
|
||||
|
||||
/// **The setter is not inert** (decision 2144). `0x49ea70` stores the latch and tail-jumps into
|
||||
/// the cascade `0x49ea90` on the new value alone — `Set(1)` raises the app's cue whether or not
|
||||
/// the value moved; `Set(0)` never does — and `0x4a00a4`/`0x4a00a9` fire `UPDATE_CHAT_WINDOWS`
|
||||
/// on every successful call, before any cascade fires it again.
|
||||
/// `0x49ea70` stores the latch and jumps into the cascade `0x49ea90` whenever the new value is 1,
|
||||
/// moved or not; `0x4a00a4`/`0x4a00a9` fire `UPDATE_CHAT_WINDOWS` on every successful call.
|
||||
#[test]
|
||||
fn the_setter_asks_for_the_cascade_on_one_and_fires_update_chat_windows() {
|
||||
let mut s = script();
|
||||
@@ -251,8 +201,7 @@ fn the_setter_asks_for_the_cascade_on_one_and_fires_update_chat_windows() {
|
||||
.unwrap();
|
||||
assert!(!s.take_guild_recruitment_cascade(), "nothing asked yet");
|
||||
|
||||
// Boots at 1; a Set(1) that moves nothing still asks — the reference's store is
|
||||
// unconditional and the jump reads only `ecx == 1`.
|
||||
// Boots at 1; a Set(1) that moves nothing still asks for the cascade.
|
||||
s.run("SetGuildRecruitmentMode(1)").unwrap();
|
||||
assert!(s.take_guild_recruitment_cascade());
|
||||
assert!(!s.take_guild_recruitment_cascade(), "drained");
|
||||
@@ -281,9 +230,8 @@ fn the_setter_asks_for_the_cascade_on_one_and_fires_update_chat_windows() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A manual join or leave of `GuildRecruitment` forces the latch to 0** — `0x49ed3d`/
|
||||
/// `0x49ef8f`, `call 0x49ea70(0)`. A player gesture, so it dirties the file; and mode 0 is the
|
||||
/// latch alone, so it asks for no cascade.
|
||||
/// A manual join or leave of `GuildRecruitment` calls `0x49ea70(0)` (`0x49ed3d`, `0x49ef8f`): it
|
||||
/// dirties the file and, as mode 0, asks for no cascade.
|
||||
#[test]
|
||||
fn a_manual_guild_recruitment_verb_resets_the_latch() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
//! The cooldown **bindings**: the per-action dynamic-state read API and the `GetTime`-space
|
||||
//! cooldown triples the stock `Cooldown.lua` machine consumes (the widget itself is the
|
||||
//! reference's own `<Model>` since decision 2019 — `tests::model_clock` runs that file verbatim).
|
||||
//! The cooldown bindings: per-action state and the `GetTime`-space triples stock `Cooldown.lua`
|
||||
//! consumes (its `<Model>` widget runs in `tests::model_clock`).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// The per-action dynamic-state API: the 1/nil conventions, IsActionInRange's tri-state, and
|
||||
/// GetActionCooldown's GetTime-space triple that goes cold at expiry.
|
||||
/// The 1/nil conventions, `IsActionInRange`'s tri-state, and a cooldown that goes cold at expiry.
|
||||
#[test]
|
||||
fn action_state_bindings_answer_the_reference_conventions() {
|
||||
let mut s = script();
|
||||
|
||||
// No state pushed: everything nil / cold.
|
||||
assert!(s.eval::<bool>("return IsUsableAction(3) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return IsActionInRange(3) == nil").unwrap());
|
||||
assert!(s
|
||||
@@ -35,7 +32,6 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
}),
|
||||
);
|
||||
|
||||
// The 1/nil pairs, exactly as the transcribed `if` reads them.
|
||||
assert!(s
|
||||
.eval::<bool>("local u, oom = IsUsableAction(3); return u == nil and oom == 1")
|
||||
.unwrap());
|
||||
@@ -45,8 +41,7 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
assert!(s
|
||||
.eval::<bool>("return IsAutoRepeatAction(3) == nil")
|
||||
.unwrap());
|
||||
// IsConsumableAction reads the SLOT, not this map (decision 1301) — it is a pure query over
|
||||
// the item template, so it arrives with the icon it gates the count beside.
|
||||
// `IsConsumableAction` reads the slot, not this state: it arrives with the icon.
|
||||
assert!(s
|
||||
.eval::<bool>("return IsConsumableAction(3) == nil")
|
||||
.unwrap());
|
||||
@@ -62,7 +57,6 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
);
|
||||
assert!(s.eval::<bool>("return IsConsumableAction(3) == 1").unwrap());
|
||||
|
||||
// GetActionCooldown: the pushed absolute start, verbatim in seconds.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local st, d, e = GetActionCooldown(3); \
|
||||
@@ -70,9 +64,8 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// 5 s later the same stored pair still answers (4 s window has 0 left → but 100+? no: it
|
||||
// expires at start+10 = now+4) …after the remaining 4 s pass, the read goes cold — the
|
||||
// stale-refeed guard (a re-fed finished pair must not replay the sweep/flash).
|
||||
// It ends at 94 + 10 = 104: live at 103.9 and cold after, so a re-fed finished pair cannot
|
||||
// replay the sweep.
|
||||
s.tick(3.9);
|
||||
assert!(s
|
||||
.eval::<bool>("local st, d = GetActionCooldown(3); return d == 10")
|
||||
@@ -82,7 +75,7 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
.eval::<bool>("local st, d, e = GetActionCooldown(3); return st == 0 and d == 0 and e == 1")
|
||||
.unwrap());
|
||||
|
||||
// An on-hold (enable == 0) cooldown never goes cold on its own — parked until the event.
|
||||
// An on-hold cooldown (enable 0) never goes cold on its own; it waits for the event.
|
||||
s.set_action_state(
|
||||
3,
|
||||
Some(ActionState {
|
||||
@@ -95,17 +88,12 @@ fn action_state_bindings_answer_the_reference_conventions() {
|
||||
.eval::<bool>("local st, d, e = GetActionCooldown(3); return d == 30 and e == 0")
|
||||
.unwrap());
|
||||
|
||||
// Clearing the state clears the reads.
|
||||
s.set_action_state(3, None);
|
||||
assert!(s.eval::<bool>("return IsCurrentAction(3) == nil").unwrap());
|
||||
}
|
||||
|
||||
/// The absolute-start triple is the anchor — both director regressions pin here. Reset-on-kill
|
||||
/// ("the cooldown indicator resets when you kill a mob"): an unrelated field flip re-pushes the
|
||||
/// same running cooldown, whose triple carries the SAME start, so the sweep holds. The vanished
|
||||
/// GCD pie ("spamming Rend during Charge never shows the pie"): a fail-clear + re-arm between
|
||||
/// feeds pushes a triple with a NEW start — under the old `(remaining, duration)` shape the two
|
||||
/// arms read byte-identical and the seam kept the first, long-elapsed anchor.
|
||||
/// The absolute start anchors the sweep: re-pushing a running cooldown (a kill flipping `usable`)
|
||||
/// keeps it, so the sweep does not reset, and a re-arm moves it even at the same duration.
|
||||
#[test]
|
||||
fn the_absolute_start_triple_holds_the_anchor_and_a_rearm_moves_it() {
|
||||
let mut s = script();
|
||||
@@ -120,8 +108,6 @@ fn the_absolute_start_triple_holds_the_anchor_and_a_rearm_moves_it() {
|
||||
.eval::<bool>("local st = GetActionCooldown(3); return math.abs(st - 100) < 1e-3")
|
||||
.unwrap());
|
||||
|
||||
// 8 s later the kill drops combat and `usable` flips; the re-push carries the same start.
|
||||
// The anchor holds at 100 — the sweep never restarts (the reset-on-kill invariant).
|
||||
s.tick(8.0);
|
||||
s.set_action_state(3, Some(cooling(true)));
|
||||
assert!(s
|
||||
@@ -131,8 +117,6 @@ fn the_absolute_start_triple_holds_the_anchor_and_a_rearm_moves_it() {
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// A RE-ARM carries a fresh start and moves the anchor — even with the same duration (the
|
||||
// vanished-pie shape: same spell, same 15 s, armed again at t=107).
|
||||
s.set_action_state(
|
||||
3,
|
||||
Some(ActionState {
|
||||
@@ -145,8 +129,6 @@ fn the_absolute_start_triple_holds_the_anchor_and_a_rearm_moves_it() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The same absolute-start law on the other two converters: the stance bar's form cooldown and
|
||||
/// the bag slot's item cooldown hold their anchors across unchanged re-pushes.
|
||||
#[test]
|
||||
fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
|
||||
let mut s = script();
|
||||
@@ -177,7 +159,7 @@ fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
|
||||
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
||||
.unwrap());
|
||||
|
||||
// Re-push both with the SAME raw triples 5 s later: the anchors hold at 50.
|
||||
// The same triples re-pushed 5 s later: the anchors hold at 50.
|
||||
s.tick(5.0);
|
||||
s.set_shapeshift_forms(vec![form()]);
|
||||
s.set_container(0, Some(bag()));
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
//! `CreateFrame(kind, name, parent, inherits)` — the **runtime** template path
|
||||
//! ([`crate::loader::apply_template`]).
|
||||
//!
|
||||
//! `local b = CreateFrame("Button", "MyButton", UIParent, "UIPanelButtonTemplate")` is the single
|
||||
//! most common line in the addon corpus, and until the loader's post-`CreateFrame` steps were
|
||||
//! factored into `Loader::decorate` the fourth argument was dropped on the floor: the addon got a
|
||||
//! bare frame with no art, no regions, no scripts, and a warning on a channel it never reads.
|
||||
//!
|
||||
//! The load-bearing property every test here turns on is that **the caller's own name wins** — a
|
||||
//! template instantiated as `Mine` publishes `MineTexture`, never `ThatTemplateTexture`, because
|
||||
//! `getglobal("MineTexture")` is what the addon's next line calls.
|
||||
//! The runtime template path, `CreateFrame`'s fourth argument ([`crate::loader::apply_template`]),
|
||||
//! and `<ScrollChild>`. The caller's name wins: a template made as `Mine` publishes `MineTexture`.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// Register templates by loading a small FrameXML document — the same door an addon's own `.xml`
|
||||
/// comes through, so nothing here is a test-only shortcut into the registry.
|
||||
/// Registers templates by loading a FrameXML document, the path an addon's own `.xml` takes.
|
||||
fn register(s: &UiScript, xml: &str) {
|
||||
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
|
||||
let report = crate::loader::load(s, &doc, &|_| None);
|
||||
@@ -25,9 +15,6 @@ fn register(s: &UiScript, xml: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole shape at once: a virtual template's `<Size>`, `<Anchors>`, `<Layers>` texture and
|
||||
/// `<Scripts><OnLoad>` all reach a frame built by `CreateFrame`'s fourth argument — and the region
|
||||
/// is named against **the instance**, not the template.
|
||||
#[test]
|
||||
fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
let mut s = script();
|
||||
@@ -65,8 +52,8 @@ fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
"the template's <Size>"
|
||||
);
|
||||
|
||||
// The template's own <Anchors> substitute `$parent` against the frame's PARENT (`0x76c5b0`) —
|
||||
// the instance's enclosing frame, not the instance itself.
|
||||
// A template's `$parent` in `<Anchors>` is the frame's first named ancestor, not the frame
|
||||
// (`0x76c5b0`).
|
||||
let (point, rel, rel_point, x, y): (String, String, String, f32, f32) = s
|
||||
.eval(
|
||||
"local p, r, rp, ox, oy = Mine:GetPoint(1) \
|
||||
@@ -78,8 +65,6 @@ fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
("TOPLEFT", "Host", "BOTTOMLEFT", 7.0, -3.0)
|
||||
);
|
||||
|
||||
// The region carries the INSTANCE's name. This is the whole point: `MineTexture` is what an
|
||||
// addon reaches for, and `ProbeTemplateTexture` would be useless to it.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("MineTexture") ~= nil"#)
|
||||
.unwrap(),
|
||||
@@ -91,8 +76,7 @@ fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
"nothing may be named after the template"
|
||||
);
|
||||
|
||||
// OnLoad fired inside CreateFrame, after the frame was decorated — the reference's ordering,
|
||||
// and what `local f = CreateFrame(...); f:Foo()` on the next line assumes.
|
||||
// The reference fires OnLoad inside `CreateFrame`, after the template is applied.
|
||||
assert_eq!(s.eval::<String>("return ProbeLoadedAs").unwrap(), "Mine");
|
||||
assert_eq!(s.eval::<f32>("return ProbeLoadWidth").unwrap(), 160.0);
|
||||
|
||||
@@ -100,8 +84,7 @@ fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// A template's nested `<Frames>` child is named against the caller too, and is really parented to
|
||||
/// the new frame — the `$parent` chain composes through `CreateFrame` exactly as through XML.
|
||||
/// A nested child is parented to the new frame, and `$parent` composes as it does through XML.
|
||||
#[test]
|
||||
fn a_nested_frames_child_is_named_against_the_caller() {
|
||||
let mut s = script();
|
||||
@@ -146,15 +129,8 @@ fn a_nested_frames_child_is_named_against_the_caller() {
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// The template argument is **one name**, on this path exactly as on the XML one — and a chain
|
||||
/// through a single name still resolves.
|
||||
///
|
||||
/// This asserted the opposite until the registry lookup was read at the bytes. `"TemplA, TemplB"`
|
||||
/// is not a list, it is a literal name nothing declared: 1.12's `CreateFrame` reaches the same
|
||||
/// `0x6ee6f0` the XML loader does (call site `0x7061dd`) with the string Lua handed it, and the
|
||||
/// loader has no splitter anywhere — comma lists are a **later**-client feature. Corroborated by
|
||||
/// the corpus: 6842 `inherits=` across 282 vanilla XML files contain **zero** comma lists, while
|
||||
/// the modern Blizzard UI source is full of them.
|
||||
/// The template argument is one name: 1.12's `CreateFrame` hands the string to the XML loader's
|
||||
/// lookup (`0x6ee6f0`, called at `0x7061dd`), which has no splitter; comma lists came later.
|
||||
#[test]
|
||||
fn a_comma_list_is_one_name_and_a_single_name_still_chains() {
|
||||
let mut s = script();
|
||||
@@ -175,8 +151,6 @@ fn a_comma_list_is_one_name_and_a_single_name_still_chains() {
|
||||
</Ui>"#,
|
||||
);
|
||||
|
||||
// A single name still walks its whole chain. This is what keeps the narrowing honest: it
|
||||
// removes the list, not the inheritance.
|
||||
s.run(r#"One = CreateFrame("Frame", "One", nil, "TemplA")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -188,8 +162,6 @@ fn a_comma_list_is_one_name_and_a_single_name_still_chains() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
assert!(s.take_warnings().is_empty());
|
||||
|
||||
// The comma form is one literal name, nothing declared it, so it RAISES — naming the whole
|
||||
// unsplit string, which is the proof it was never split.
|
||||
let err = s
|
||||
.run(r#"Both = CreateFrame("Frame", "Both", nil, "TemplA, TemplB")"#)
|
||||
.expect_err("a comma list is one name, and it misses");
|
||||
@@ -205,12 +177,8 @@ fn a_comma_list_is_one_name_and_a_single_name_still_chains() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The case fold, with the four names the corpus actually mis-cases.
|
||||
///
|
||||
/// The compare is `SStrCmpI` → `_strnicmp`, and the trap that decides it is the bucket hash:
|
||||
/// `SStrHash 0x64b3f0` uppercases before mixing, so a mis-cased name lands in the same bucket and
|
||||
/// the stored-hash pre-check passes rather than short-circuiting. ASCII-only, like every other fold
|
||||
/// in this engine.
|
||||
/// The lookup folds ASCII case: `SStrHash` (`0x64b3f0`) uppercases before hashing, so a mis-cased
|
||||
/// name lands in the same bucket, and the compare is `SStrCmpI`.
|
||||
#[test]
|
||||
fn a_template_name_is_matched_case_insensitively() {
|
||||
let mut s = script();
|
||||
@@ -222,7 +190,7 @@ fn a_template_name_is_matched_case_insensitively() {
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
// Exactly the shape `CT_RaidAssist/CT_RAOptions.xml` ships: `RA` written `Ra`.
|
||||
// The mis-casing `CT_RaidAssist/CT_RAOptions.xml` ships: `RA` written `Ra`.
|
||||
s.run(r#"Cased = CreateFrame("Frame", "Cased", nil, "CT_RaCheckButtonTemplate")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -234,21 +202,9 @@ fn a_template_name_is_matched_case_insensitively() {
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// An unresolvable template name **raises, and creates nothing**.
|
||||
///
|
||||
/// This test asserted the exact opposite — *"the reference creates the frame anyway, and an addon's
|
||||
/// next line already assumes it did"* — until the bytes were read. `0x7061dd` looks the name up and
|
||||
/// on NULL falls into `luaL_error(L, "CreateFrame(): Couldn't find inherited node \"%s\"")`, which
|
||||
/// **never returns**: `luaG_errormsg` and `luaD_throw` contain no `ret` between them and end in
|
||||
/// `longjmp`, so the `xor eax,eax; ret` that follows the call is dead code. (That trailing `ret` is
|
||||
/// a fact about `luaL_error`'s `int` return type in C, not about reachability — which is exactly
|
||||
/// how the old claim was arrived at.)
|
||||
///
|
||||
/// Both flavours of "unusable" raise, because the registry only ever holds `virtual="true"`
|
||||
/// elements, so a name declared without it misses the lookup identically to a name nothing declared.
|
||||
///
|
||||
/// The ordering is asserted too: the miss precedes construction and name publication (`0x706208` /
|
||||
/// `0x70622d`), so nothing partial is left behind — no frame, and no global.
|
||||
/// A template name that misses the lookup raises at `0x7061dd`, before the frame is built or its
|
||||
/// name published (`0x706208`, `0x70622d`). The registry holds only `virtual="true"` elements, so
|
||||
/// a non-virtual name misses like an undeclared one.
|
||||
#[test]
|
||||
fn an_unresolvable_template_raises_and_creates_nothing() {
|
||||
let s = script();
|
||||
@@ -266,7 +222,6 @@ fn an_unresolvable_template_raises_and_creates_nothing() {
|
||||
"the raise must carry the reference's message and the name: {text}"
|
||||
);
|
||||
|
||||
// Nothing partial: no global published, and the name never reached the arena.
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(r#"return getglobal("{frame}") == nil"#))
|
||||
.unwrap(),
|
||||
@@ -274,9 +229,7 @@ fn an_unresolvable_template_raises_and_creates_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
// And it is ordinary Lua propagation — `pcall` catches it, which is what keeps one bad
|
||||
// CreateFrame inside a handler from taking the client down (the widget dispatcher runs every
|
||||
// handler under `lua_pcall`).
|
||||
// An ordinary Lua error: `pcall` catches it, as the widget dispatcher's `lua_pcall` does.
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
r#"return (pcall(CreateFrame, "Button", "Caught", nil, "NoSuchTemplate"))"#
|
||||
@@ -286,15 +239,8 @@ fn an_unresolvable_template_raises_and_creates_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The two shape mismatches, neither of which may panic or drop the frame.
|
||||
///
|
||||
/// A **region** template (a virtual `<Texture>`) asked for as a frame: its frame-shaped content
|
||||
/// still lands, its region-only content cannot, and the message says which.
|
||||
///
|
||||
/// A **kind that disagrees with the template's tag**: the frame keeps the kind `CreateFrame` was
|
||||
/// given — no template can retype a frame that already exists — so the `<Button>`-only parts of a
|
||||
/// Button template simply do not apply to a Frame, and, crucially, are not *attempted* (calling
|
||||
/// `SetNormalTexture` on a plain Frame would be an error, not a warning).
|
||||
/// A region template on a frame applies its frame-shaped content and warns; a template whose tag
|
||||
/// disagrees with the kind keeps the kind, and its tag-only parts are skipped, not attempted.
|
||||
#[test]
|
||||
fn a_region_template_or_a_mismatched_kind_is_named_never_fatal() {
|
||||
let mut s = script();
|
||||
@@ -355,7 +301,7 @@ fn a_region_template_or_a_mismatched_kind_is_named_never_fatal() {
|
||||
"the Button-only steps are SKIPPED, not attempted and failed"
|
||||
);
|
||||
|
||||
// 3 · the same template, asked for as the kind it was written as — the control.
|
||||
// 3 · the control: the same template, asked for as the kind it was written as.
|
||||
s.run(r#"AsButton = CreateFrame("Button", "AsButton", nil, "ProbeButtonTemplate")"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f32>("return AsButton:GetWidth()").unwrap(), 90.0);
|
||||
@@ -371,13 +317,8 @@ fn a_region_template_or_a_mismatched_kind_is_named_never_fatal() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`<ScrollChild>` gives a ScrollFrame its range** — the loader element that was missing, and
|
||||
/// the whole remaining distance for an addon's scrolling list (decision 1205).
|
||||
///
|
||||
/// The single child is instantiated via the same `0x6ee280` path `<Frames>` uses, then stored as
|
||||
/// the scroll child. Without it a `ScrollFrame` has nothing to pan, so `GetVerticalScrollRange()`
|
||||
/// is 0, `SetVerticalScroll` clamps to 0, and `OnVerticalScroll` fires with 0 forever — the list
|
||||
/// never moves and nothing errors, which is why no instrument could see it.
|
||||
/// `<ScrollChild>` builds its one child through the `<Frames>` path (`0x6ee280`) and stores it as
|
||||
/// the scroll child, which gives the frame its range.
|
||||
#[test]
|
||||
fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
|
||||
let mut s = script();
|
||||
@@ -398,19 +339,18 @@ fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
|
||||
);
|
||||
s.resolve();
|
||||
|
||||
// The child exists, is named against the FRAME (`$parent` `0x76c5b0`), and is the scroll child.
|
||||
// `$parent` names the child against the scroll frame (`0x76c5b0`).
|
||||
assert!(s.eval::<bool>("return RollerChild ~= nil").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return Roller:GetScrollChild() == RollerChild")
|
||||
.unwrap());
|
||||
// ...and the range is the overhang: a 400-tall child in a 100-tall window scrolls 300.
|
||||
// The range is the overhang: a 400-tall child in a 100-tall window scrolls 300.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Roller:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
300.0,
|
||||
"no child means range 0, which is the silent failure this element fixes"
|
||||
);
|
||||
// The scroll actually takes, rather than clamping to zero.
|
||||
s.run("Roller:SetVerticalScroll(120)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Roller:GetVerticalScroll()").unwrap(),
|
||||
@@ -419,14 +359,9 @@ fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The scroll range is the child's SUBTREE, not the child frame's own height** (decision 1338:
|
||||
/// `0x786e30` seeds a bbox and walks `0x786f80` recursively over the child's region and child-frame
|
||||
/// lists).
|
||||
///
|
||||
/// The geometry is the reference reader's own: `ItemTextPageScrollChild` is declared **10×10**
|
||||
/// around a 270×304 page. Measured as the child's height that is range 0 — a book that cannot be
|
||||
/// scrolled — which is exactly what shipped before this. The second assertion is the mutation
|
||||
/// check welded in: it is the number the old law returned.
|
||||
/// The scroll range is the child's whole subtree, not its own height: `0x786e30` seeds a box and
|
||||
/// `0x786f80` walks the child's regions and child frames. The shape is `ItemTextPageScrollChild`,
|
||||
/// 10×10 around a 270×304 page (`ItemTextFrame.xml:198`).
|
||||
#[test]
|
||||
fn the_scroll_range_is_the_childs_whole_subtree() {
|
||||
let mut s = script();
|
||||
@@ -459,16 +394,13 @@ fn the_scroll_range_is_the_childs_whole_subtree() {
|
||||
204.0,
|
||||
"the 304-tall page inside the 10-tall child, less the 100-tall window"
|
||||
);
|
||||
// The mutation check: under the old law the child's own 10 never reaches the window's 100, so
|
||||
// the range clamps to zero and the reader's scrollbar has nowhere to go.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ReaderChild:GetHeight()").unwrap(),
|
||||
10.0,
|
||||
"and the child itself really is the reference's 10 — the range does not come from it"
|
||||
);
|
||||
|
||||
// A hidden branch contributes nothing: the client guards both list walks on visibility, which
|
||||
// is what stops a window's parked art from inventing travel nobody can use.
|
||||
// A hidden branch adds nothing: the reference guards both list walks on visibility.
|
||||
s.run("Page:Hide()").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -479,8 +411,7 @@ fn the_scroll_range_is_the_childs_whole_subtree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty `<ScrollChild>` is an error (`0x786bc0`) — the reference's own behaviour, and the
|
||||
/// honest one: a ScrollFrame that declares a child and has none is a typo, not a design.
|
||||
/// The reference reports an empty `<ScrollChild>` as an error (`0x786bc0`) and keeps the frame.
|
||||
#[test]
|
||||
fn an_empty_scroll_child_is_reported() {
|
||||
let s = script();
|
||||
@@ -497,6 +428,5 @@ fn an_empty_scroll_child_is_reported() {
|
||||
"errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
// ...and the frame still exists — the client logs and carries on (decision 0068).
|
||||
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
|
||||
}
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
//! **What one widget method lookup costs** — the bench behind the `__index` table (decision 2310).
|
||||
//!
|
||||
//! Every `frame:SetPoint(...)` in FrameXML and in every addon begins with a table index on the
|
||||
//! wrapper, which misses and falls to the metatable's `__index`. That path is on the hottest edge
|
||||
//! this engine has: Questie redraws its map notes with ~19 widget calls per cluster over hundreds
|
||||
//! of clusters, and a live sample of a quest-accept spike put **20 % of the whole UI tick** inside
|
||||
//! the metamethod dispatch alone — before any method body ran.
|
||||
//!
|
||||
//! The two rows below are the measurement that claim rests on, and the regression guard for it:
|
||||
//!
|
||||
//! - `miss` — `local m = f.NoSuchMethod`, the duck-type probe addons use (`if f.SetValue then`).
|
||||
//! It is the WORST case: a dispatcher walks every registry in the kind chain and then the shared
|
||||
//! table before answering nil.
|
||||
//! - `hit` — `local m = f.GetWidth`, the lookup every real call pays.
|
||||
//! What one widget method lookup through the wrapper's `__index` costs: every call pays a hit,
|
||||
//! and a miss (the duck-type probe `if f.SetValue then`) walks every registry in the kind chain.
|
||||
//!
|
||||
//! Run: `cargo test --release -p benilla-ui --lib -- --ignored --nocapture dispatch_bench`.
|
||||
|
||||
@@ -19,11 +7,10 @@ use std::time::Instant;
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Lookups per timed loop — big enough that the loop's own overhead is a rounding error at the
|
||||
/// nanosecond scale these rows live at.
|
||||
/// Lookups per timed loop, enough that the loop's own overhead is noise at nanosecond scale.
|
||||
const N: usize = 400_000;
|
||||
|
||||
/// Timed repeats per row; the reported number is the fastest (see [`dispatch_bench`]'s `floor`).
|
||||
/// Timed repeats per row; each row reports the fastest.
|
||||
const ROUNDS: usize = 5;
|
||||
|
||||
fn ns_per(t: Instant, n: usize) -> f64 {
|
||||
@@ -44,12 +31,7 @@ fn dispatch_bench() {
|
||||
)
|
||||
.expect("fixtures");
|
||||
|
||||
// A plain Lua table with a table `__index` — the floor this path could reach, and what the
|
||||
// reference client's flat `{name, lua_CFunction}` probe costs in C.
|
||||
// **The MINIMUM of [`ROUNDS`] runs, never the mean.** This machine builds while it measures —
|
||||
// another build on the same machine saturates ten cores — and a mean of a contended loop reads
|
||||
// whatever else was running. The minimum is the closest thing to the uncontended cost that a
|
||||
// shared machine can report, and it is what makes two rows of one run comparable at all.
|
||||
// The minimum of `ROUNDS` runs: a mean would measure whatever else was running.
|
||||
let floor = |what: &str, chunk: &str| {
|
||||
let mut best = f64::INFINITY;
|
||||
for _ in 0..ROUNDS {
|
||||
@@ -60,6 +42,7 @@ fn dispatch_bench() {
|
||||
println!("[dispatch] {what:>26} {best:7.1} ns");
|
||||
};
|
||||
|
||||
// The floor: a plain table with a table `__index`, like the reference's flat C method probe.
|
||||
floor(
|
||||
"baseline (table meta)",
|
||||
&format!(
|
||||
@@ -101,10 +84,8 @@ fn dispatch_bench() {
|
||||
&format!("for _ = 1, {N} do local m = S.GetWidth end"),
|
||||
);
|
||||
|
||||
// The whole call, not just the lookup — what an addon's `f:GetWidth()` actually costs, split
|
||||
// so the two taxes are separable: `GetFrameLevel` is a frame-only binding and measures one
|
||||
// Rust hop, while `GetWidth` is one of [`crate::script::region_map`]'s 19 shared names and
|
||||
// pays a SECOND hop — the bridge re-enters Lua to call the per-side arm.
|
||||
// Whole calls: `GetFrameLevel` is a frame-only binding (one Rust hop); `GetWidth` is one of
|
||||
// `region_map`'s shared names, whose bridge re-enters Lua for the per-side arm.
|
||||
floor(
|
||||
"GetFrameLevel() call",
|
||||
&format!("for _ = 1, {N} do local l = F:GetFrameLevel() end"),
|
||||
@@ -118,16 +99,8 @@ fn dispatch_bench() {
|
||||
&format!("for _ = 1, {N} do F:SetPoint(\"CENTER\", 0, 0) end"),
|
||||
);
|
||||
|
||||
// **The settle tax.** Every geometry GETTER (`GetWidth`, `GetHeight`, `GetLeft`, `GetPoint`,
|
||||
// `GetCenter`) calls `layout_methods::settle`, i.e. a whole `resolve_layout`. The tier-1 epoch
|
||||
// gate makes that free while nothing has moved — which is what the `GetWidth() call` row above
|
||||
// measures — but ANY layout write in between reopens it, so the read-after-write idiom pays a
|
||||
// resolve per read. That idiom is not exotic: Astrolabe repositions a minimap icon with
|
||||
// `GetWidth` → `ClearAllPoints` → `SetPoint`, per icon, and Questie places dozens.
|
||||
//
|
||||
// Here the model holds four frames, so a resolve is nearly free and the gap is the mechanism
|
||||
// alone. In a live client the roster is thousands of frames and ten thousand anchored regions
|
||||
// — the same gap, three orders of magnitude wider.
|
||||
// Every geometry getter runs `layout_methods::settle`: free while nothing has moved, a whole
|
||||
// `resolve_layout` after any layout write, so read-after-write pays one per read.
|
||||
floor(
|
||||
"write only",
|
||||
&format!("for i = 1, {N} do F:SetPoint(\"CENTER\", i, 0) end"),
|
||||
@@ -143,12 +116,8 @@ fn dispatch_bench() {
|
||||
),
|
||||
);
|
||||
|
||||
// **The control that says WHERE the bridged calls' extra microsecond goes.** `__direct` is one
|
||||
// mlua closure; `__wrapped` is one mlua closure that calls another through `Function::call` —
|
||||
// the exact shape `region_map`'s shared arm has. The gap between these two rows is what a
|
||||
// re-entrant Lua call costs, and it is the number that decides whether those 19 names are worth
|
||||
// restructuring into a single Rust body with an internal branch (the reference's own shape: one
|
||||
// C function doing a virtual dispatch on the receiver).
|
||||
// Controls: `__direct` is one mlua closure, `__wrapped` one that calls another through
|
||||
// `Function::call` as `region_map`'s shared arm does; their gap is one re-entrant Lua call.
|
||||
{
|
||||
let lua = s.lua();
|
||||
let direct = lua
|
||||
@@ -160,9 +129,7 @@ fn dispatch_bench() {
|
||||
.expect("wrapped fn");
|
||||
lua.globals().set("__wrapped", wrapped).expect("set");
|
||||
|
||||
// The same pair again in the SHAPE the bridge actually has: a variadic `MultiValue` whose
|
||||
// first value is the receiver table. `Table` and `MultiValue` are the two conversions the
|
||||
// scalar controls above leave out, and between them they are most of the gap.
|
||||
// The same pair in the bridge's shape: a variadic `MultiValue` led by the receiver table.
|
||||
let mv_direct = lua
|
||||
.create_function(|_, args: mlua::MultiValue| Ok(args.len() as i64))
|
||||
.expect("mv direct");
|
||||
|
||||
@@ -37,9 +37,8 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() {
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
|
||||
// The renderable content, in painter order: parent's bg, child's bg, child's text — parent frame
|
||||
// draws before child (earlier insertion, same strata/level), each frame's regions grouped behind
|
||||
// it (order.rs ZKey).
|
||||
// Painter order: the parent draws before the child (earlier insertion, same strata and level),
|
||||
// each frame's regions with it.
|
||||
let content: Vec<String> = quads
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
@@ -57,14 +56,13 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() {
|
||||
]
|
||||
);
|
||||
|
||||
// SetAllPoints resolves each region to its owner frame's rect. Parent → Rect(300,0,600,400);
|
||||
// child (TOPLEFT+10,-10 of parent, 100×50) → Rect(540,10,590,110).
|
||||
// `SetAllPoints` gives each region its frame's rect: the parent Rect(300, 0, 600, 400), the
|
||||
// child (TOPLEFT +10, -10 of the parent, 100×50) Rect(540, 10, 590, 110).
|
||||
let child_text = quads
|
||||
.iter()
|
||||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Hello"))
|
||||
.unwrap();
|
||||
assert_eq!(child_text.rect, Some(Rect::new(540.0, 10.0, 590.0, 110.0)));
|
||||
// The fontstring got a red vertex color via SetVertexColor(1,0,0,1).
|
||||
assert!(
|
||||
matches!(&child_text.content, QuadContent::Text { color: Some(c), .. } if *c == [1.0, 0.0, 0.0, 1.0])
|
||||
);
|
||||
@@ -75,7 +73,6 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() {
|
||||
.unwrap();
|
||||
assert_eq!(parent_bg.rect, Some(Rect::new(300.0, 0.0, 600.0, 400.0)));
|
||||
|
||||
// ZKey order is ascending and strictly increasing across the emitted list.
|
||||
let zs: Vec<u64> = quads.iter().map(|q| q.z).collect();
|
||||
let mut sorted = zs.clone();
|
||||
sorted.sort_unstable();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! RegisterEvent + fire_event via BOTH conventions (`0x704d50` / `0x704f10`).
|
||||
//!
|
||||
//! The handler's extra arguments are read through 5.0's implicit `arg` table, not `select(n, ...)`:
|
||||
//! `...` as a value is not in this VM's grammar (decision 2101), because it is not in the 1.12
|
||||
//! client's. The point of the test is unchanged — the same handler sees the legacy globals
|
||||
//! (`this`, `event`, `arg1`) AND the positional arguments.
|
||||
//! Event registration and dispatch (`0x704d50`, `0x704f10`). Handlers read varargs through Lua
|
||||
//! 5.0's implicit `arg` table: `...` is not a value in the 1.12 client's grammar.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
@@ -57,7 +53,7 @@ fn globals_are_restored_after_firing_nesting_safe() {
|
||||
)
|
||||
.unwrap();
|
||||
s.fire_event("E", vec![ScriptValue::Str("x".into())]);
|
||||
// After firing, the prior global values must be restored (`0x704f10`'s set-then-restore).
|
||||
// `0x704f10` sets the globals for the handler and restores the prior values after.
|
||||
let (t, e, a): (String, String, String) = s.eval("return this, event, arg1").unwrap();
|
||||
assert_eq!(
|
||||
(t.as_str(), e.as_str(), a.as_str()),
|
||||
@@ -82,11 +78,8 @@ fn handler_errors_are_collected_not_panicked() {
|
||||
assert!(errs[0].contains("boom"), "{errs:?}");
|
||||
}
|
||||
|
||||
/// The cross-frame dispatch ORDER law: the client's
|
||||
/// per-event listener list is tail-appended (`0x7052d0`) and walked head-first (`0x703e50`) —
|
||||
/// **FIFO: registration order = firing order**. Duplicate registration keeps the original
|
||||
/// position (`0x702264` dup ret); unregister+re-register moves to the tail. The ZoneText frames
|
||||
/// depend on this: both write PVPInfoTextString on one event — the last writer decides.
|
||||
/// Each event's listener list is tail-appended (`0x7052d0`) and walked head-first (`0x703e50`);
|
||||
/// a duplicate registration returns early (`0x702264`).
|
||||
#[test]
|
||||
fn events_fire_in_registration_order_fifo() {
|
||||
let mut s = script();
|
||||
@@ -106,43 +99,26 @@ fn events_fire_in_registration_order_fifo() {
|
||||
s.fire_event("E", vec![]);
|
||||
assert_eq!(s.eval::<String>("return order").unwrap(), "ABC");
|
||||
|
||||
// Duplicate registration keeps A's position (the client's dup early-ret).
|
||||
// A duplicate registration keeps A's position.
|
||||
s.run("FA:RegisterEvent('E'); order = ''").unwrap();
|
||||
s.fire_event("E", vec![]);
|
||||
assert_eq!(s.eval::<String>("return order").unwrap(), "ABC");
|
||||
|
||||
// Unregister + re-register moves B to the TAIL (the node is freed, the re-add appends).
|
||||
// Unregistering frees B's node, so re-registering appends it at the tail.
|
||||
s.run("FB:UnregisterEvent('E'); FB:RegisterEvent('E'); order = ''")
|
||||
.unwrap();
|
||||
s.fire_event("E", vec![]);
|
||||
assert_eq!(s.eval::<String>("return order").unwrap(), "ACB");
|
||||
}
|
||||
|
||||
/// **`HasScript` answers "can this widget CARRY that kind", not "does it have one set".**
|
||||
///
|
||||
/// That distinction is the verb's whole purpose, and every corpus caller depends on it: they ask
|
||||
/// before hooking, precisely when nothing is set yet.
|
||||
///
|
||||
/// ```lua
|
||||
/// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
|
||||
/// local script = parent:GetScript("OnMouseDown")
|
||||
/// parent:SetScript("OnMouseDown", function() … end)
|
||||
/// end
|
||||
/// ```
|
||||
///
|
||||
/// It was the top session-start blocker — 32 of 39 `attempt to call method` failures were this one
|
||||
/// name — and implementing it took survivors from 41 to 69.
|
||||
///
|
||||
/// The known over-permission is asserted too, so it is a recorded divergence rather than a
|
||||
/// discovery: our table is flat where the reference's is per widget type, so a plain Frame answers
|
||||
/// true for a Button-only kind. Exact for the base kinds, which is what the corpus asks about.
|
||||
/// Callers ask before hooking, when nothing is set yet (`Tablet-2.0.lua:2409`). Our kind table is
|
||||
/// flat where the reference's is per widget type, so a Frame also answers true for `OnClick`.
|
||||
#[test]
|
||||
fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
|
||||
let s = script();
|
||||
s.run(r#"f = CreateFrame("Frame", "HasScriptProbe")"#)
|
||||
.unwrap();
|
||||
|
||||
// True with NOTHING set — the case every caller is actually in.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return f:HasScript("OnMouseDown")"#)
|
||||
.unwrap(),
|
||||
@@ -154,7 +130,7 @@ fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
|
||||
"an unknown kind is false, not true"
|
||||
);
|
||||
|
||||
// Tablet's exact idiom, run end to end: guard, read the (absent) handler, install one, fire it.
|
||||
// Tablet's idiom, end to end.
|
||||
let fired: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -170,19 +146,15 @@ fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
|
||||
.unwrap();
|
||||
assert!(fired, "the guarded hook must install and run");
|
||||
|
||||
// The recorded divergence: flat table, so a Frame says true for a Button-only kind. The
|
||||
// reference says false. Pinned so making SCRIPT_KINDS per-type has to come here and decide.
|
||||
// The flat table: a Frame says true for a Button-only kind, where the reference says false.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return f:HasScript("OnClick")"#).unwrap(),
|
||||
"over-permissive by design today — see the comment at the binding"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The walk steps by a next saved BEFORE the handler runs** (`0x703ee8`; decision 1324): a
|
||||
/// handler that unregisters ITSELF mid-dispatch cannot rob its successor. This is AceEvent-2.0's
|
||||
/// fire-once idiom for `PLAYER_LOGIN`/`VARIABLES_LOADED` — its frame unregisters inside the
|
||||
/// handler, and the index-walk this replaces skipped whichever addon registered right after it
|
||||
/// (Bagnon_Forever's DB never initialized; the director's SaveBagData error dialogs).
|
||||
/// The walk saves the next node before the handler runs (`0x703ee8`); AceEvent-2.0's fire-once
|
||||
/// idiom unregisters inside its own handler.
|
||||
#[test]
|
||||
fn a_self_unregistering_handler_does_not_rob_its_successor() {
|
||||
let mut s = script();
|
||||
@@ -208,15 +180,13 @@ fn a_self_unregistering_handler_does_not_rob_its_successor() {
|
||||
vec!["WalkA", "WalkB", "WalkC"],
|
||||
"the once-idiom's self-removal must not skip the next listener"
|
||||
);
|
||||
// The removal held: a second fire reaches only A and C.
|
||||
s.fire_event("E", vec![]);
|
||||
let log: Vec<String> = s.eval("return log").unwrap();
|
||||
assert_eq!(log, vec!["WalkA", "WalkB", "WalkC", "WalkA", "WalkC"]);
|
||||
}
|
||||
|
||||
/// A handler that unregisters the walk's SAVED next ends the dispatch there — the reference frees
|
||||
/// that node and walks into zeroed links (an accident we render as a deterministic stop) — while a
|
||||
/// frame registered mid-dispatch tail-appends and is still visited.
|
||||
/// Unregistering the walk's saved next ends the dispatch, as the reference frees that node and
|
||||
/// walks into its zeroed links; a frame registered mid-dispatch tail-appends and is still visited.
|
||||
#[test]
|
||||
fn mid_dispatch_removal_of_the_next_stops_and_append_is_visited() {
|
||||
let mut s = script();
|
||||
@@ -271,16 +241,9 @@ fn mid_dispatch_removal_of_the_next_stops_and_append_is_visited() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Frame:RegisterAllEvents()` — the frame's `OnEvent` receives every event dispatched
|
||||
/// (`0x774c20`, table `0x878ec0`, argc 1, arity 0), and `UnregisterAllEvents` clears that state
|
||||
/// along with the per-event ones.
|
||||
///
|
||||
/// **The clearing half is the load-bearing one.** AceEvent-2.0 — shipped by 63 vanilla addons and
|
||||
/// 6 of the top 20 — turns all-events on once (`AceEvent.frame:RegisterAllEvents()`), deliberately
|
||||
/// stops calling `frame:UnregisterEvent(event)` while it is on, and gets back to per-event
|
||||
/// registration by calling `frame:UnregisterAllEvents()` and then re-registering each event it
|
||||
/// still wants. A `RegisterAllEvents` that survived that call would leave every Ace2 addon on the
|
||||
/// whole event stream for the session.
|
||||
/// `RegisterAllEvents` (`0x774c20`, table `0x878ec0`, argc 1, arity 0) sends every event to the
|
||||
/// frame's `OnEvent`; `UnregisterAllEvents` clears it too, which is how AceEvent-2.0 gets back to
|
||||
/// per-event registration.
|
||||
#[test]
|
||||
fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
let mut s = script();
|
||||
@@ -293,10 +256,8 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Arity 0 — the verb answers nothing.
|
||||
assert_eq!(s.arity("All:RegisterAllEvents()").unwrap(), 0);
|
||||
|
||||
// Anything dispatched now reaches it, including an event no name list could have enumerated.
|
||||
for ev in [
|
||||
"PLAYER_LOGIN",
|
||||
"UNIT_HEALTH",
|
||||
@@ -314,9 +275,7 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
"SOME_SERVER_EVENT_NOBODY_LISTED"
|
||||
);
|
||||
|
||||
// Registering twice is a no-op, and a frame holding BOTH an all-events registration and a
|
||||
// RegisterEvent for the same event is one listener, not two — the same rule `RegisterEvent`'s
|
||||
// own `if not already in the list` holds one level down.
|
||||
// All-events plus an explicit registration of the same event is still one listener.
|
||||
s.run(r#"seen = {}; All:RegisterAllEvents(); All:RegisterEvent("UNIT_HEALTH")"#)
|
||||
.unwrap();
|
||||
s.fire_event("UNIT_HEALTH", vec![]);
|
||||
@@ -326,7 +285,7 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
"fired once, not twice"
|
||||
);
|
||||
|
||||
// UnregisterAllEvents clears BOTH: the explicit UNIT_HEALTH registration and the all-events one.
|
||||
// UnregisterAllEvents clears both registrations.
|
||||
s.run("seen = {}; All:UnregisterAllEvents()").unwrap();
|
||||
for ev in ["UNIT_HEALTH", "PLAYER_LOGIN"] {
|
||||
s.fire_event(ev, vec![]);
|
||||
@@ -337,7 +296,7 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
"the all-events registration does not outlive UnregisterAllEvents"
|
||||
);
|
||||
|
||||
// …and the AceEvent path back: re-register the individual events it still wants.
|
||||
// AceEvent's way back: re-register the events it still wants.
|
||||
s.run(r#"All:RegisterEvent("UNIT_HEALTH")"#).unwrap();
|
||||
for ev in ["UNIT_HEALTH", "PLAYER_LOGIN"] {
|
||||
s.fire_event(ev, vec![]);
|
||||
@@ -350,10 +309,6 @@ fn register_all_events_takes_every_event_and_unregister_all_clears_it() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// An all-events frame dispatches AFTER the event's own listeners — where it would sit if the
|
||||
/// registration were expanded into every per-event list, since it joined later than they did.
|
||||
/// Cross-frame order is a law consumers depend on (the two ZoneText frames writing one FontString);
|
||||
/// it does not stop being one because a listener asked for everything.
|
||||
#[test]
|
||||
fn an_all_events_listener_runs_after_the_events_own() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! Font objects as first-class Lua objects ([`crate::script::font`]).
|
||||
//!
|
||||
//! Every test here is driven **the way the 218-addon corpus drives it** — the bare-global argument,
|
||||
//! the no-arg `CreateFontString`, `CreateFont` — rather than the way our own shipped XML does,
|
||||
//! because the string-name form our XML uses was the only one we accepted and it is the form
|
||||
//! almost nobody writes (6 corpus sites against 3,180).
|
||||
//! Font objects as first-class Lua objects ([`crate::script::font`]), driven as addons drive them:
|
||||
//! the bare global, the no-argument `CreateFontString`, `CreateFont`.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{FontObject, JustifyH, JustifyV, Outline, QuadContent, UiScript};
|
||||
@@ -36,14 +32,8 @@ fn painted(s: &UiScript, text: &str) -> (Option<String>, Option<f32>, Option<[f3
|
||||
.unwrap_or_else(|| panic!("no text quad reading {text:?}"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Publication
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── Publication ──
|
||||
|
||||
/// A `<Font name="X">` in FrameXML becomes the **global `X`**, an object of type `Font`.
|
||||
///
|
||||
/// This is the whole gap: we registered the resolved record and never named it, so every
|
||||
/// `SetFontObject(GameFontNormal)` in the ecosystem was handed nil.
|
||||
#[test]
|
||||
fn a_declared_font_is_published_as_a_global_object() {
|
||||
let s = script();
|
||||
@@ -69,13 +59,11 @@ fn a_declared_font_is_published_as_a_global_object() {
|
||||
s.eval::<String>("return GameFontNormal:GetName()").unwrap(),
|
||||
"GameFontNormal"
|
||||
);
|
||||
// Object identity is stable: two reads of the global are the same object, which is what makes
|
||||
// `if fs:GetFontObject() == GameFontNormal` work at all.
|
||||
// Two reads of the global are one object, which `fs:GetFontObject() == GameFontNormal` needs.
|
||||
assert!(s
|
||||
.eval::<bool>("return GameFontNormal == GameFontNormal")
|
||||
.unwrap());
|
||||
|
||||
// The declared paint reads back through the FontInstance getters.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return GameFontNormal:GetFont()")
|
||||
.unwrap(),
|
||||
@@ -102,9 +90,7 @@ fn a_declared_font_is_published_as_a_global_object() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`Tablet-2.0.lua:289`, verbatim.** `_, headerSize = GameTooltipHeaderText:GetFont()` — the
|
||||
/// single most-called font-object read in the corpus (268 sites across the four embedded Ace
|
||||
/// libraries). Until the global existed, Tablet fell to its hardcoded 14/12 guard branch.
|
||||
/// The header-size read at `Tablet-2.0.lua:289`, verbatim.
|
||||
#[test]
|
||||
fn tablet_reads_the_tooltip_header_size_off_the_global() {
|
||||
let s = script();
|
||||
@@ -140,9 +126,8 @@ fn tablet_reads_the_tooltip_header_size_off_the_global() {
|
||||
assert_eq!((header, normal), (14.0, 12.0));
|
||||
}
|
||||
|
||||
/// **Requirement 5: publishing must not change what `inherits=` resolves to.** The chain is still
|
||||
/// flattened once, at load, and the published object carries the *flattened* values — the derived
|
||||
/// font sees the parent's face and its own height override.
|
||||
/// `inherits=` is flattened once, at load, where the reference links it live (`0x770c60`), and
|
||||
/// the published object carries the flattened values.
|
||||
#[test]
|
||||
fn an_inheriting_font_still_flattens_to_the_same_values() {
|
||||
let s = script();
|
||||
@@ -159,7 +144,6 @@ fn an_inheriting_font_still_flattens_to_the_same_values() {
|
||||
</Font>
|
||||
</Ui>"#,
|
||||
);
|
||||
// The Rust-side record — unchanged by publication.
|
||||
let derived = s.font_object("DerivedFont").expect("registered");
|
||||
assert_eq!(derived.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
|
||||
assert_eq!(derived.height, Some(18.0));
|
||||
@@ -167,7 +151,6 @@ fn an_inheriting_font_still_flattens_to_the_same_values() {
|
||||
assert_eq!(derived.outline, Outline::Normal);
|
||||
assert_eq!(derived.justify_h, Some(JustifyH::Center));
|
||||
assert!(derived.shadow.is_some(), "the shadow inherits too");
|
||||
// …and the same values through the published object.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return DerivedFont:GetFont()")
|
||||
.unwrap(),
|
||||
@@ -179,16 +162,10 @@ fn an_inheriting_font_still_flattens_to_the_same_values() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// SetFontObject / GetFontObject
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── SetFontObject and GetFontObject ──
|
||||
|
||||
/// **`Gratuity-2.0.lua:47-59`, verbatim** — the block that took five corpus addons down at load
|
||||
/// with `bad argument #2: error converting Lua nil to String`.
|
||||
///
|
||||
/// Three separate things have to hold for it: `CreateFontString()` with **no arguments**,
|
||||
/// `SetFontObject` taking the **object**, and `AddFontStrings` existing at all (fixing only the
|
||||
/// first two moves the death one line down).
|
||||
/// The tooltip build at `Gratuity-2.0.lua:47-59`: it needs a no-argument `CreateFontString`,
|
||||
/// `SetFontObject` taking the object, and `AddFontStrings`.
|
||||
#[test]
|
||||
fn gratuity_builds_its_thirty_line_scan_tooltip() {
|
||||
let s = script();
|
||||
@@ -218,7 +195,6 @@ fn gratuity_builds_its_thirty_line_scan_tooltip() {
|
||||
.expect("Gratuity's CreateTooltip must not raise");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// The font object actually landed on the lines it made.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32)>("return vars.Llines[7]:GetFont()")
|
||||
.unwrap(),
|
||||
@@ -229,13 +205,10 @@ fn gratuity_builds_its_thirty_line_scan_tooltip() {
|
||||
.unwrap(),
|
||||
"GameFontNormal"
|
||||
);
|
||||
// And Gratuity's own next move — `ClearLines` over the grown stack — still works.
|
||||
// Gratuity's next call: `ClearLines` over the grown line stack.
|
||||
s.run("vars.tooltip:ClearLines()").unwrap();
|
||||
}
|
||||
|
||||
/// The object form and the string form must resolve to **the same paint**. Both are kept: the
|
||||
/// object is what 3,180 of 3,186 corpus sites pass, the string is what our own `assets/ui` and 6
|
||||
/// corpus sites pass.
|
||||
#[test]
|
||||
fn set_font_object_takes_the_object_or_the_name() {
|
||||
let mut s = script();
|
||||
@@ -275,14 +248,12 @@ fn set_font_object_takes_the_object_or_the_name() {
|
||||
Some([0.25, 0.5, 0.75, 1.0])
|
||||
)
|
||||
);
|
||||
// …and both name the same object back.
|
||||
assert!(s
|
||||
.eval::<bool>("return byObject:GetFontObject() == byName:GetFontObject()")
|
||||
.unwrap());
|
||||
|
||||
// nil is the THIRD form the reference's own usage string names
|
||||
// (`SetFontObject(font or "font" or nil)`, `.rdata 0x87c5cc`): it severs the link and leaves
|
||||
// the paint standing.
|
||||
// nil, the third form in the reference's usage string (`.rdata 0x87c5cc`), unlinks the object
|
||||
// and leaves the paint.
|
||||
s.run("byName:SetFontObject(nil)").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return byName:GetFontObject() == nil")
|
||||
@@ -293,14 +264,11 @@ fn set_font_object_takes_the_object_or_the_name() {
|
||||
Some(11.0),
|
||||
"unlinking must not repaint"
|
||||
);
|
||||
// A frame or an unknown name is still an ERROR, never a silent no-op (1203/1205/1211's class).
|
||||
assert!(s.run("byName:SetFontObject(f)").is_err());
|
||||
assert!(s.run("byName:SetFontObject('NoSuchFont')").is_err());
|
||||
}
|
||||
|
||||
/// `GetFontObject` returns the **object**, not a name — because `Dewdrop-2.0.lua:2181` indexes the
|
||||
/// result immediately: `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())`.
|
||||
/// 65 sites across 62 corpus addons do exactly this.
|
||||
/// `GetFontObject` returns the object, not a name, which `Dewdrop-2.0.lua:2181` indexes at once.
|
||||
#[test]
|
||||
fn dewdrop_recolors_a_row_from_its_own_font_object() {
|
||||
let mut s = script();
|
||||
@@ -332,17 +300,10 @@ fn dewdrop_recolors_a_row_from_its_own_font_object() {
|
||||
assert_eq!(painted(&s, "row").2, Some([1.0, 1.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Mutability — the decision, pinned
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── Mutability ──
|
||||
|
||||
/// **The mutability decision.** A font object is a shared record with a **live** link: mutating it
|
||||
/// re-paints every FontString that inherits it, which is why addons mutate one at all. What a
|
||||
/// FontString set *for itself* since its own `SetFontObject` survives that re-paint — our
|
||||
/// `FONTINSTANCE+0x038 explicitlySetMask`.
|
||||
///
|
||||
/// If this ever regresses to a one-shot copy, `inherited` stops moving and the test says so; if the
|
||||
/// mask is dropped, `overridden` loses its red.
|
||||
/// A font object is a live link: mutating it repaints every FontString that inherits it, except
|
||||
/// what a FontString set for itself, whose setter cleared that property's inherit-mask bit.
|
||||
#[test]
|
||||
fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
let mut s = script();
|
||||
@@ -375,7 +336,6 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
assert_eq!(painted(&s, "inherited").1, Some(12.0));
|
||||
assert_eq!(painted(&s, "overridden").2, Some([1.0, 0.0, 0.0, 1.0]));
|
||||
|
||||
// The mutation the whole feature exists for.
|
||||
s.run(
|
||||
r#"
|
||||
ThemeFont:SetFont("Fonts\\MORPHEUS.TTF", 20)
|
||||
@@ -385,7 +345,6 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Both followed the face and size…
|
||||
assert_eq!(
|
||||
painted(&s, "inherited"),
|
||||
(
|
||||
@@ -399,17 +358,14 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
Some("Fonts\\MORPHEUS.TTF".into())
|
||||
);
|
||||
assert_eq!(painted(&s, "overridden").1, Some(20.0));
|
||||
// …but the one that had set its own colour kept it.
|
||||
assert_eq!(
|
||||
painted(&s, "overridden").2,
|
||||
Some([1.0, 0.0, 0.0, 1.0]),
|
||||
"an explicitly-set property must survive a font-object mutation"
|
||||
);
|
||||
|
||||
// Severance SURVIVES a re-point. The reference's inheritMask bit (`+0x2c`,
|
||||
// FontString `+0xd4`) is cleared by the local setter and never restored, so "a FontString that
|
||||
// set its own colour stays severed even across a later SetFontObject". Our first cut reset the
|
||||
// mask here and this assertion was its inverse.
|
||||
// Severance survives a re-point: the local setter clears the reference's inheritMask bit
|
||||
// (`+0x2c`, FontString `+0xd4`) and nothing restores it.
|
||||
s.run("b:SetFontObject(ThemeFont)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -417,16 +373,13 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
Some([1.0, 0.0, 0.0, 1.0]),
|
||||
"a re-point must not restore inheritance of a severed property"
|
||||
);
|
||||
// Dewdrop still works, because it re-reads the colour off the new object explicitly every
|
||||
// refresh (`Dewdrop-2.0.lua:2181`) rather than relying on inheritance.
|
||||
// Dewdrop re-reads the colour off the object every refresh (`Dewdrop-2.0.lua:2181`).
|
||||
s.run("b:SetTextColor(b:GetFontObject():GetTextColor())")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "overridden").2, Some([0.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// A mutation of a font object nobody inherits touches nothing, and a FontString that inherits a
|
||||
/// *different* object is not caught in the sweep.
|
||||
#[test]
|
||||
fn propagation_is_scoped_to_the_object_that_changed() {
|
||||
let mut s = script();
|
||||
@@ -458,14 +411,10 @@ fn propagation_is_scoped_to_the_object_that_changed() {
|
||||
assert_eq!(painted(&s, "two").1, Some(12.0));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CreateFont
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── CreateFont ──
|
||||
|
||||
/// **`_Nameplates.lua:149` + `:129` + `:212`, end to end** — mint a font object at runtime, set it
|
||||
/// up, then paint a FontString with it. Plus `!OmniCC/main.lua:40-41`'s half: the name is published
|
||||
/// as a global even when the return value is thrown away, and `SetFont`'s return is the font-file
|
||||
/// validity probe OmniCC uses it as.
|
||||
/// `_Nameplates.lua:149`, `:129` and `:212`, then `!OmniCC/main.lua:40-41`: the name is published
|
||||
/// even when the return is dropped, and `SetFont`'s return says whether the face loaded.
|
||||
#[test]
|
||||
fn create_font_mints_publishes_and_paints() {
|
||||
let mut s = script();
|
||||
@@ -494,7 +443,6 @@ fn create_font_mints_publishes_and_paints() {
|
||||
)
|
||||
);
|
||||
|
||||
// The return value and the published global are the SAME object — OmniCC reads only the global.
|
||||
assert!(s
|
||||
.eval::<bool>("return _NameplatesNameplateFont == Nameplate.Font")
|
||||
.unwrap());
|
||||
@@ -504,8 +452,7 @@ fn create_font_mints_publishes_and_paints() {
|
||||
"Font"
|
||||
);
|
||||
|
||||
// `!OmniCC/main.lua:40-41` verbatim: create, discard the return, use the global, and branch on
|
||||
// SetFont's return to detect a saved font path that is no longer usable.
|
||||
// `!OmniCC/main.lua:40-41`: create, drop the return, then branch on the global's `SetFont`.
|
||||
let reverted = s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
@@ -528,9 +475,7 @@ fn create_font_mints_publishes_and_paints() {
|
||||
("Fonts\\FRIZQT__.TTF".to_string(), 20.0, String::new())
|
||||
);
|
||||
|
||||
// A name that already names a font object hands the existing one back UNCHANGED — the
|
||||
// non-destructive reading (see `create_font`'s doc). The alternative would let
|
||||
// `CreateFont("GameFontNormal")` blank the shipped registry entry the whole UI inherits.
|
||||
// A name that already names a font object returns that object unchanged (`0x7839ab`).
|
||||
s.run("again = CreateFont('_NameplatesNameplateFont')")
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return again == Nameplate.Font").unwrap());
|
||||
@@ -540,14 +485,12 @@ fn create_font_mints_publishes_and_paints() {
|
||||
("Fonts\\SKURRI.TTF".to_string(), 10.0, String::new())
|
||||
);
|
||||
|
||||
// A nameless CreateFont is an error: the name IS the publication. The EMPTY name is not —
|
||||
// the reference's `lua_isstring` gate takes it (unlike the XML path), so we do too.
|
||||
// A nameless `CreateFont` raises; the empty name passes the reference's `lua_isstring` gate.
|
||||
assert!(s.run("CreateFont()").is_err());
|
||||
s.run("CreateFont('')").unwrap();
|
||||
}
|
||||
|
||||
/// **`FonzAppraiser/mods/gui/gui.lua:27-30`** — `CreateFont` then `CopyFontObject(<a shipped
|
||||
/// object>)` then override the face. The corpus's only Font-on-Font call.
|
||||
/// The sequence at `FonzAppraiser/mods/gui/gui.lua:27-30`.
|
||||
#[test]
|
||||
fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
|
||||
let s = script();
|
||||
@@ -571,7 +514,6 @@ fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
|
||||
"#,
|
||||
)
|
||||
.expect("FonzAppraiser's font setup must not raise");
|
||||
// The copy brought the colour and justification over; the SetFont replaced the face and size.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return small_number_font:GetFont()")
|
||||
.unwrap(),
|
||||
@@ -587,7 +529,6 @@ fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
|
||||
.unwrap(),
|
||||
"RIGHT"
|
||||
);
|
||||
// …and the object it copied FROM is untouched.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return GameFontHighlightSmall:GetFont()")
|
||||
.unwrap(),
|
||||
@@ -595,10 +536,8 @@ fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `Spacing` pair is **deliberately absent** (we model no line spacing, and a stored-but-never
|
||||
/// drawn setter is this codebase's recurring silent-drop bug). It must fail LOUDLY rather than
|
||||
/// quietly accept a number nobody honours — this test is the tripwire on that choice, and it flips
|
||||
/// the day spacing becomes real.
|
||||
/// Line spacing is not built: the reference's `SetSpacing` is absent here, so a call raises rather
|
||||
/// than store a number nothing draws.
|
||||
#[test]
|
||||
fn the_unmodelled_spacing_pair_fails_loudly() {
|
||||
let s = script();
|
||||
@@ -607,9 +546,8 @@ fn the_unmodelled_spacing_pair_fails_loudly() {
|
||||
assert!(s.eval::<bool>("return SomeFont.SetSpacing == nil").unwrap());
|
||||
}
|
||||
|
||||
/// A button's per-state label fonts take the object or the name too (the corpus splits 5 to 4
|
||||
/// across the trio), and — because they are stored as NAMES and re-resolved at every extract — a
|
||||
/// later mutation of that font object reaches the label with no further call.
|
||||
/// A button's state fonts take the object or the name; they are stored as names and re-resolved at
|
||||
/// every extract, so a later mutation of the object reaches the label.
|
||||
#[test]
|
||||
fn button_state_fonts_take_the_object_and_follow_its_mutation() {
|
||||
let mut s = script();
|
||||
@@ -647,15 +585,11 @@ fn button_state_fonts_take_the_object_and_follow_its_mutation() {
|
||||
)
|
||||
);
|
||||
|
||||
// The string form still works alongside it.
|
||||
s.run("b:SetHighlightFontObject('GameFontNormal')").unwrap();
|
||||
}
|
||||
|
||||
/// **A `CreateFont` object holds nothing, so pointing a FontString at it copies nothing.**
|
||||
/// The reference gates every merge (`0x770910`/`0x770800`) on the source's own has-a-value mask,
|
||||
/// and a fresh font has `mask == 0` — the FontString keeps exactly what it had, no blanking and no
|
||||
/// fallback to a default. Our first cut wrote face/height through unconditionally and would have
|
||||
/// wiped it.
|
||||
/// The reference gates every merge (`0x770910`/`0x770800`) on the source's has-a-value mask, and a
|
||||
/// fresh `CreateFont` object's mask is 0, so the FontString keeps what it had.
|
||||
#[test]
|
||||
fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
|
||||
let mut s = script();
|
||||
@@ -691,7 +625,7 @@ fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
|
||||
),
|
||||
"an unset property must copy nothing, not blank the region"
|
||||
);
|
||||
// The link still moved, so once the blank font is dressed the string follows it.
|
||||
// The link still moves: once the blank font is dressed, the string follows it.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return fs:GetFontObject():GetName()")
|
||||
.unwrap(),
|
||||
@@ -702,8 +636,7 @@ fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
|
||||
assert_eq!(painted(&s, "keep").0, Some("Fonts\\MORPHEUS.TTF".into()));
|
||||
}
|
||||
|
||||
/// `SetFont` returns **1 or nil**, not a boolean — the reference's exact return shape, and what
|
||||
/// `!OmniCC/main.lua:41` branches on.
|
||||
/// The reference's return shape, not a boolean; `!OmniCC/main.lua:41` branches on it.
|
||||
#[test]
|
||||
fn set_font_returns_one_or_nil() {
|
||||
let s = script();
|
||||
@@ -716,15 +649,8 @@ fn set_font_returns_one_or_nil() {
|
||||
assert!(s.eval::<bool>("return f:SetFont('', 12) == nil").unwrap());
|
||||
}
|
||||
|
||||
/// The justify law is **one** table in the binary (`.rdata 0x811ad0`) and must be one
|
||||
/// transcription here: a `FontString` and a `<Font>` object have to answer identically, token for
|
||||
/// token and raise for raise.
|
||||
///
|
||||
/// They did not. Both sides were written separately from the same law and each drifted its own
|
||||
/// way — the FontString had no `GetJustifyH`/`GetJustifyV` at all (real entries `0x79e5f0` /
|
||||
/// `0x79e7f0`), the Font object silently `.trim()`ed its argument where `SStrCmpI` compares the
|
||||
/// whole string, and both quietly answered CENTER for a string the reference raises on. Every
|
||||
/// test on both sides passed throughout. This is the net that makes the next drift fail.
|
||||
/// The justify tokens are one reference table (`.rdata 0x811ad0`) matched whole and case-blind by
|
||||
/// `SStrCmpI`; a FontString (getters `0x79e5f0`/`0x79e7f0`) and a `<Font>` object answer alike.
|
||||
#[test]
|
||||
fn both_tables_speak_one_justify_law() {
|
||||
let s = script();
|
||||
@@ -736,7 +662,7 @@ fn both_tables_speak_one_justify_law() {
|
||||
.unwrap();
|
||||
|
||||
for obj in ["fs", "fo"] {
|
||||
// The client's ctor default `0x212` = CENTER | MIDDLE | 0x200, read through each mask.
|
||||
// The reference's constructor default `0x212`, CENTER | MIDDLE | 0x200, read per axis.
|
||||
let h = s
|
||||
.eval::<String>(&format!("return {obj}:GetJustifyH()"))
|
||||
.unwrap();
|
||||
@@ -759,7 +685,7 @@ fn both_tables_speak_one_justify_law() {
|
||||
}
|
||||
}
|
||||
|
||||
// A non-token raises the reference's own usage string rather than coercing to CENTER.
|
||||
// A non-token raises the reference's usage string rather than coercing to CENTER.
|
||||
for verb in ["SetJustifyH", "SetJustifyV"] {
|
||||
let err = s
|
||||
.run(&format!("{obj}:{verb}('MIDDLE_LEFT')"))
|
||||
@@ -771,27 +697,21 @@ fn both_tables_speak_one_justify_law() {
|
||||
);
|
||||
}
|
||||
|
||||
// Whole-string: a trailing space is a miss. The Font object used to trim it away.
|
||||
// The match is whole-string: a trailing space is a miss.
|
||||
assert!(
|
||||
s.run(&format!("{obj}:SetJustifyH('LEFT ')")).is_err(),
|
||||
"{obj}:SetJustifyH must not trim its argument"
|
||||
);
|
||||
|
||||
// A cross-axis token matches the table, so it is accepted with no error — the one place
|
||||
// we knowingly stop short of the law (the reference clears the axis; see `script::justify`).
|
||||
// A cross-axis token is in the table and raises nothing. The reference then clears the
|
||||
// axis; the FontString does too, the `<Font>` object leaves it as it was.
|
||||
s.run(&format!("{obj}:SetJustifyH('TOP')")).unwrap();
|
||||
s.run(&format!("{obj}:SetJustifyV('LEFT')")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetFont` is one routine (`0x79f210`) with three entry points — Font `0x7a0270`, FontString
|
||||
/// `0x79d4f0`, EditBox `0x797210` — so all three must answer identically, and none of them did.
|
||||
///
|
||||
/// The FontString's hand-written copy answered the boolean `true` for *everything*, including the
|
||||
/// empty-path load failure that `!OmniCC/main.lua:41`'s `if not f:SetFont(saved, size)` probes;
|
||||
/// the `<Font>` object's took `Option` arguments and so answered **nil** for `SetFont()` where the
|
||||
/// reference raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`). A behavioural
|
||||
/// differential across both tables is what surfaced it — arity alone had matched.
|
||||
/// `SetFont` is one routine (`0x79f210`) behind three entries, Font `0x7a0270`, FontString
|
||||
/// `0x79d4f0` and EditBox `0x797210`; a bad argument raises its usage string (`0x87c69c`).
|
||||
#[test]
|
||||
fn set_font_is_one_routine_on_both_tables() {
|
||||
let s = script();
|
||||
@@ -799,7 +719,6 @@ fn set_font_is_one_routine_on_both_tables() {
|
||||
.unwrap();
|
||||
|
||||
for obj in ["fs", "fo"] {
|
||||
// Success is the NUMBER 1 — never a boolean, and never zero values.
|
||||
let n = s
|
||||
.eval::<f32>(&format!(
|
||||
"return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', 12)"
|
||||
@@ -814,15 +733,14 @@ fn set_font_is_one_routine_on_both_tables() {
|
||||
"{obj}:SetFont must answer the number 1, not true"
|
||||
);
|
||||
|
||||
// An empty path is the LOAD FAILURE edge: nil, falsey, and nothing raised.
|
||||
// An empty path is a load failure: nil, and nothing raised.
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return {obj}:SetFont('', 12) == nil"))
|
||||
.unwrap(),
|
||||
"{obj}:SetFont('') must answer nil"
|
||||
);
|
||||
|
||||
// A missing or non-string path, or a missing height, is an ARGUMENT error — it raises,
|
||||
// which is a different thing from the nil above and used to be conflated with it.
|
||||
// A missing or non-string path, or a missing height, is an argument error and raises.
|
||||
for bad in ["", "'Fonts\\\\FRIZQT__.TTF'", "nil, 12", "{}, 12"] {
|
||||
let err = s
|
||||
.run(&format!("{obj}:SetFont({bad})"))
|
||||
@@ -833,7 +751,7 @@ fn set_font_is_one_routine_on_both_tables() {
|
||||
);
|
||||
}
|
||||
|
||||
// Both `lua_isstring` and `lua_isnumber` coerce, so a numeric string is accepted for either.
|
||||
// Both `lua_isstring` and `lua_isnumber` coerce, so either accepts a numeric string.
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', '14') == 1"
|
||||
@@ -844,17 +762,9 @@ fn set_font_is_one_routine_on_both_tables() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A cross-axis token **erases** its axis, and the two readers then disagree — faithfully.
|
||||
///
|
||||
/// `SetJustifyH("TOP")` parses (`0x08`), contributes nothing to mask `0x07`, and raises nothing.
|
||||
/// `GetJustifyH()` afterwards answers the literal `"UNKNOWN"` (`0x6f1a00` → `.data 0x838044`),
|
||||
/// while the glyphs keep drawing **centred**: the ui→gx translator `0x44d420` is a priority ladder
|
||||
/// whose per-axis register is pre-set to `1` between the `test` and the `jcc`, so an all-clear axis
|
||||
/// exits with CENTER still in it. Reading the getter's answer into the draw path — or mapping the
|
||||
/// bitmask onto the gx enum with a `0` default — inverts the axis to LEFT.
|
||||
///
|
||||
/// Reached by 13 corpus sites (`FonzAppraiser` ×12, `Roid-Macros`) writing
|
||||
/// `SetJustifyV("CENTER")` when they mean MIDDLE.
|
||||
/// `SetJustifyH("TOP")` parses (`0x08`), sets nothing in the axis mask `0x07` and raises nothing;
|
||||
/// `GetJustifyH` then answers `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`), yet the ui-to-gx
|
||||
/// translator `0x44d420` pre-sets each axis to 1, so the text still draws centred, not LEFT.
|
||||
#[test]
|
||||
fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
|
||||
let mut s = script();
|
||||
@@ -867,13 +777,13 @@ fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A real token lands, so the erase below is visibly an erase and not a no-op.
|
||||
// A real token first, so the erase below is not a no-op.
|
||||
s.run("fs:SetJustifyH('LEFT') fs:SetJustifyV('TOP')")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return fs:GetJustifyH()").unwrap(), "LEFT");
|
||||
assert_eq!(s.eval::<String>("return fs:GetJustifyV()").unwrap(), "TOP");
|
||||
|
||||
// The cross-axis pair: each erases the OTHER axis it was aimed at, raising nothing.
|
||||
// Each setter gets the other axis's token: it clears its own axis and raises nothing.
|
||||
s.run("fs:SetJustifyH('TOP')").unwrap();
|
||||
s.run("fs:SetJustifyV('CENTER')").unwrap();
|
||||
assert_eq!(
|
||||
@@ -886,7 +796,6 @@ fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
|
||||
"UNKNOWN"
|
||||
);
|
||||
|
||||
// ...and the draw path answers CENTER/MIDDLE for that same state.
|
||||
s.resolve();
|
||||
let (h, v) = s
|
||||
.extract()
|
||||
@@ -908,17 +817,9 @@ fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The font block is a **per-table membership fact**, and the shared shadow-accessor (`0x79f910`,
|
||||
/// FontString's own `0x79dbe0`) names the six: *"Exposed on: FontString, Font object, EditBox,
|
||||
/// MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button."* We shipped it on two; these
|
||||
/// are the third and fourth.
|
||||
///
|
||||
/// `BigWigs/Plugins/Messages.lua:212` — `self.msgframe:SetFontObject(GameFontNormalLarge)` on a
|
||||
/// frame it has just given `SetInsertMode("TOP")` — died there every session.
|
||||
///
|
||||
/// The second assertion is the trap: a message frame's lines fall back to **LEFT** when it has no
|
||||
/// declared `<FontString>`, while a freshly created `RegionData` defaults to the FontString's own
|
||||
/// CENTER. Creating the style region on demand must not silently re-justify the frame.
|
||||
/// The shared font block (`0x79f910`, FontString's `0x79dbe0`) is on six tables: FontString, Font,
|
||||
/// EditBox, MessageFrame, ScrollingMessageFrame and SimpleHTML, not Button. A message frame with no
|
||||
/// `<FontString>` draws LEFT, and creating its style region (CENTER by default) must keep it LEFT.
|
||||
#[test]
|
||||
fn the_font_block_reaches_both_message_frame_tables() {
|
||||
let mut s = script();
|
||||
@@ -940,7 +841,7 @@ fn the_font_block_reaches_both_message_frame_tables() {
|
||||
"MsgBlockFont",
|
||||
"{obj}:GetFontObject must answer the OBJECT it was given"
|
||||
);
|
||||
// The shared `0x79f210` contract, same as every other table that carries it.
|
||||
// The shared `SetFont` contract (`0x79f210`).
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return {obj}:SetFont('Fonts\\\\MORPHEUS.TTF', 16) == 1"
|
||||
@@ -952,11 +853,10 @@ fn the_font_block_reaches_both_message_frame_tables() {
|
||||
.eval::<(mlua::Value, f32, String)>(&format!("return {obj}:GetFont()"))
|
||||
.unwrap();
|
||||
assert_eq!(height, 16.0, "{obj}:GetFont reads back what SetFont wrote");
|
||||
// The four-value getters pin at 4 for every one of the six tables (`0x79f9b3`).
|
||||
// The four-value getters return four values on all six tables (`0x79f9b3`).
|
||||
assert_eq!(s.arity(&format!("{obj}:GetShadowColor()")).unwrap(), 4);
|
||||
}
|
||||
|
||||
// Styling the frame must not move its text: the lines still run flush LEFT.
|
||||
s.run("mf:AddMessage('flush left please')").unwrap();
|
||||
s.resolve();
|
||||
let j = s
|
||||
@@ -978,18 +878,8 @@ fn the_font_block_reaches_both_message_frame_tables() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `CreateFontString`'s **third argument** applies a font object — the argument we accepted and
|
||||
/// then dropped on the floor (decision 1255).
|
||||
///
|
||||
/// 49 corpus call sites across 5 distinct addons pass one, and every one names a font object:
|
||||
/// AckisRecipeList (28), CustomNameplates (10), _LazyPig (6), LibAboutPanel (4), ColorPickerPlus.
|
||||
/// Five separate addons, so this is not one library file replicated. Ignoring it was 1203's class —
|
||||
/// the addon asks for a font, the call succeeds, and the text comes out in the default with no
|
||||
/// failure anywhere to point at.
|
||||
///
|
||||
/// The **order** is the part a reimplementation loses: the font-object registry is tried FIRST and
|
||||
/// the template registry only on a font miss (`0x773d39` then `0x773d47`). A template-first
|
||||
/// resolver would miss all 49 of these, because `inherits=` is one argument over two namespaces.
|
||||
/// The third argument is looked up as a font object first, and as a template only on a miss
|
||||
/// (`0x773d39`, then `0x773d47`).
|
||||
#[test]
|
||||
fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
|
||||
let s = script();
|
||||
@@ -1004,7 +894,7 @@ fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
|
||||
</Ui>"#,
|
||||
);
|
||||
|
||||
// Exactly `_LazyPig/LazyPigMenu.lua:88`'s line.
|
||||
// The call at `_LazyPig/LazyPigMenu.lua:88`.
|
||||
s.run(r#"FS = Host:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")"#)
|
||||
.expect("the font-object form must be accepted");
|
||||
let (face, height, _flags) = s
|
||||
@@ -1013,8 +903,7 @@ fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
|
||||
assert_eq!(face, "Fonts\\FRIZQT__.TTF");
|
||||
assert_eq!(height, 12.0);
|
||||
|
||||
// A name in NEITHER registry raises — the same contract 1253 set for CreateFrame, and the same
|
||||
// bytes (`luaL_error`, which never returns).
|
||||
// A name in neither registry raises (`luaL_error`), as an unknown `CreateFrame` template does.
|
||||
let err = s
|
||||
.run(r#"Bad = Host:CreateFontString(nil, "ARTWORK", "NoSuchFontOrTemplate")"#)
|
||||
.expect_err("a name in neither registry must raise");
|
||||
@@ -1023,22 +912,13 @@ fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
|
||||
"the raise must name what was looked up: {err}"
|
||||
);
|
||||
|
||||
// Texture takes the same argument through the same resolver (1 real corpus site).
|
||||
// `CreateTexture` takes the same third argument through the same resolver.
|
||||
s.run(r#"TX = Host:CreateTexture(nil, "OVERLAY")"#)
|
||||
.expect("the two-argument form still works");
|
||||
}
|
||||
|
||||
/// **`SetFont`'s nil is a LOAD failure, and only the host knows** (decision 2103).
|
||||
///
|
||||
/// The reference answers the number 1 or nil (`0x79f345`/`0x79f361`), and the nil originates in
|
||||
/// the font factory at `0x5c1ae0` — a path that names no readable file. `!OmniCC/main.lua:41`
|
||||
/// reads it exactly that way (`if not Font:SetFont(saved, size) then revert end`), and an addon
|
||||
/// that ships its own faces (MSBT ships thirty-one, under `Interface\Addons\…\Fonts\`) is the
|
||||
/// case that makes the answer depend on a store rather than on the string.
|
||||
///
|
||||
/// The engine-less default is the other half: with no probe a non-empty path answers 1, because a
|
||||
/// VM with no font backend has nothing for a load to fail against. That is the opposite default to
|
||||
/// `SetTexture`'s (1322) and the reason is in `Model::font_probe`.
|
||||
/// The reference answers 1 or nil (`0x79f345`/`0x79f361`), nil when the font factory (`0x5c1ae0`)
|
||||
/// finds no readable file; with no host probe, any non-empty path answers 1.
|
||||
#[test]
|
||||
fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
|
||||
let mut s = script();
|
||||
@@ -1051,8 +931,7 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
|
||||
.unwrap());
|
||||
assert!(s.eval::<bool>("return FS:SetFont('', 12) == nil").unwrap());
|
||||
|
||||
// With one, the store decides — and the face it refused is NOT adopted, so the region keeps
|
||||
// the last font that did load.
|
||||
// With a probe the store decides, and a refused face is not adopted.
|
||||
s.set_font_probe(Box::new(|path| {
|
||||
path.eq_ignore_ascii_case("interface\\addons\\msbt\\fonts\\porky.ttf")
|
||||
|| path.eq_ignore_ascii_case("fonts\\frizqt__.ttf")
|
||||
@@ -1080,20 +959,8 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
|
||||
assert_eq!(height, 18.0, "…while the height, which never fails, is set");
|
||||
}
|
||||
|
||||
/// **Mik's Scrolling Battle Text, end to end** — the exact sequence the addon runs per event,
|
||||
/// pinned because it is the shape a whole class of "the addon's font did not take" reports wears
|
||||
/// (decisions 2103, 2112).
|
||||
///
|
||||
/// MSBT's twenty scroll-area FontStrings are declared `inherits="MasterFont"` — the reference's
|
||||
/// root font object, which carries a `<Shadow>` and **nothing else**: no face, no height, no
|
||||
/// outline. Each animation then does, in this order, on the string it recycles:
|
||||
/// `ClearAllPoints · SetFont(<its own TTF>, 18, "OUTLINE") · SetTextColor · SetText · SetAlpha ·
|
||||
/// SetPoint`. Every one of those five paint axes has to reach the extracted quad *unmodified* by
|
||||
/// the object the string inherits — the addon's face over the object's absent one, the addon's
|
||||
/// height over the renderer default, the addon's outline over the object's `NONE` (which is
|
||||
/// indistinguishable from "unset", see [`Outline`]), its colour over the object's absent one, and
|
||||
/// the animation's fade as the quad's own alpha. The `<Shadow>` is the one thing that IS inherited,
|
||||
/// and it must still be there.
|
||||
/// MSBT's per-event sequence over the stock `MasterFont` (`Fonts.xml:55`, a `<Shadow>` only): its
|
||||
/// own face, size, outline, colour and fade reach the quad, and the shadow is inherited.
|
||||
#[test]
|
||||
fn msbt_paints_its_own_face_size_outline_and_fade_over_the_font_object_it_inherits() {
|
||||
let mut s = script();
|
||||
@@ -1161,7 +1028,6 @@ fn msbt_paints_its_own_face_size_outline_and_fade_over_the_font_object_it_inheri
|
||||
}
|
||||
ref other => panic!("expected a Text quad, got {other:?}"),
|
||||
}
|
||||
// The Lua-visible echo agrees with the quad — the readback an addon branches on.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return MSBTFrameIncomingText1:GetFont()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
//! Small frame-API wins: GetLocale, SetAllPoints, and GetPoint readback.
|
||||
//! Frame-API basics: `GetLocale`, `SetAllPoints` and `GetPoint` readback.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Two era names, asserted together because neither earns a file of its own.
|
||||
///
|
||||
/// This test carried two more legs until decision 2142, `SetFormattedText` and `SetShown`, and
|
||||
/// both went with the verbs: neither is in a 1.12 method table. What the first of them actually
|
||||
/// proved — that `%N$s` reorders rather than consumes — is `string.format`'s own behaviour and is
|
||||
/// pinned where it lives, in [`crate::strings`].
|
||||
#[test]
|
||||
fn get_locale_and_set_all_points() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// GetLocale — benilla is enUS-data-only.
|
||||
// `GetLocale`: benilla is enUS-only.
|
||||
assert_eq!(s.eval::<String>("return GetLocale()").unwrap(), "enUS");
|
||||
|
||||
s.run(
|
||||
@@ -28,7 +22,6 @@ fn get_locale_and_set_all_points() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// SetAllPoints pins the full target rect, parent-default and by-name forms alike.
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -53,7 +46,7 @@ fn getpoint_reads_back_anchors() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Screen-anchored: relativeTo is nil (no UIParent wrapper yet — stated).
|
||||
// Screen-anchored: `relativeTo` reads nil.
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -63,7 +56,7 @@ fn getpoint_reads_back_anchors() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok, "screen anchor reads back");
|
||||
// Frame-anchored: relativeTo is the same wrapper table (stable identity).
|
||||
// Frame-anchored: `relativeTo` is the same wrapper table.
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
//! Lua 5.0's iterator-less generic-for, restored in the vendored VM (decision 1215).
|
||||
//!
|
||||
//! `for k, v in someTable do` is Lua 5.0 syntax that 5.1 removed at the OPCODE level. 183 of 218
|
||||
//! corpus addons are reached by it and it is the first session-start error for 60 of them — three
|
||||
//! quarters of everything that breaks in a running session. The fix is 5.0's `OP_TFORPREP` folded
|
||||
//! into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`.
|
||||
//!
|
||||
//! Each test below pins one detail that was byte-verified in the real 1.12.1 client, because each
|
||||
//! is a place where a *reasonable* guess diverges from what the client does. The `__call` case is
|
||||
//! the sharpest: two of the three conditions somebody would naturally write for this behave
|
||||
//! differently there.
|
||||
//! The Lua 5.0 dialect the vendored 5.1 VM restores: the iterator-less generic-for (5.0's
|
||||
//! `OP_TFORPREP` folded into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`), long-string
|
||||
//! nesting and the constructor compat-semicolon.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// **The construct works at all** — the whole point, in the shape the corpus uses.
|
||||
///
|
||||
/// `Ace.lua`'s is the canonical one and Ace is embedded across half the ecosystem:
|
||||
/// `for _, lang in self.langs do … end`, with no `pairs` and no iterator function.
|
||||
#[test]
|
||||
fn a_table_generator_iterates_like_lua_50() {
|
||||
let s = script();
|
||||
@@ -31,7 +19,7 @@ fn a_table_generator_iterates_like_lua_50() {
|
||||
.unwrap();
|
||||
assert_eq!(seen, 6, "every pair must be visited exactly once");
|
||||
|
||||
// The array shape too, and the KEY must be the real key rather than a counter.
|
||||
// The array shape: the key is the real key, not a counter.
|
||||
let keys: String = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -45,12 +33,7 @@ fn a_table_generator_iterates_like_lua_50() {
|
||||
assert_eq!(keys, "1x2y");
|
||||
}
|
||||
|
||||
/// **The condition is a bare type-tag test, so a table carrying `__call` STILL gets `next`.**
|
||||
///
|
||||
/// This is the discriminating case, and the reason the patch does not test "is not callable" or
|
||||
/// "has no `__call`" — both of those are reasonable readings and both diverge from the client here.
|
||||
/// The 1.12 handler makes **no** metatable access whatsoever (no `luaT_gettmbyobj`, no
|
||||
/// `tryfuncTM`), so the `__call` is never consulted and the table is iterated.
|
||||
/// The 1.12 handler tests only the type tag, never the metatable, so `__call` is not consulted.
|
||||
#[test]
|
||||
fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
|
||||
let s = script();
|
||||
@@ -72,11 +55,7 @@ fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The callee is the global `next`, read fresh at each loop entry — so an addon can replace it.**
|
||||
///
|
||||
/// Observable behaviour of the real client, not an accident: the lookup is a raw read of the
|
||||
/// thread's globals table, performed once per loop entry. An addon that assigns `next = myfn`
|
||||
/// changes every later generic-for in the session. Reproduced rather than improved on.
|
||||
/// The callee is a raw read of the global `next` at each loop entry, so an addon can replace it.
|
||||
#[test]
|
||||
fn the_substituted_generator_is_the_live_global_next() {
|
||||
let s = script();
|
||||
@@ -100,14 +79,11 @@ fn the_substituted_generator_is_the_live_global_next() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Only a table is substituted.** A function generator is untouched (the ordinary 5.1 path), and
|
||||
/// a non-table, non-function generator still raises on the FIRST ITERATION rather than at entry —
|
||||
/// the client's tag mismatch falls through to the normal call path.
|
||||
/// Any other generator takes the normal call path, so a number raises on the first iteration.
|
||||
#[test]
|
||||
fn only_a_table_is_substituted_and_other_types_still_raise() {
|
||||
let s = script();
|
||||
|
||||
// The ordinary iterator form is completely unaffected.
|
||||
let via_pairs: i64 = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -120,7 +96,6 @@ fn only_a_table_is_substituted_and_other_types_still_raise() {
|
||||
.unwrap();
|
||||
assert_eq!(via_pairs, 3);
|
||||
|
||||
// A custom iterator function still drives its own loop.
|
||||
let custom: i64 = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -139,7 +114,6 @@ fn only_a_table_is_substituted_and_other_types_still_raise() {
|
||||
"an explicit generator/state/control triple is untouched"
|
||||
);
|
||||
|
||||
// A number generator is not a table: it must still raise, and say so.
|
||||
let err = s
|
||||
.eval::<i64>("local n = 0 for k, v in 42 do n = n + 1 end return n")
|
||||
.unwrap_err()
|
||||
@@ -150,8 +124,6 @@ fn only_a_table_is_substituted_and_other_types_still_raise() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **An empty table terminates immediately** rather than looping — `next(t, nil)` on an empty table
|
||||
/// returns nil, and the loop's own continue test is unchanged by the substitution.
|
||||
#[test]
|
||||
fn an_empty_table_generator_terminates() {
|
||||
let s = script();
|
||||
@@ -161,9 +133,7 @@ fn an_empty_table_generator_terminates() {
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
/// **Nested and repeated loops each get their own substitution.** The patch rewrites the loop's own
|
||||
/// registers, so an inner loop must not disturb an outer one, and a loop entered twice must
|
||||
/// substitute twice (the registers are reloaded from the expression each entry).
|
||||
/// The patch rewrites the loop's own registers, reloaded from the expression at each entry.
|
||||
#[test]
|
||||
fn nested_and_repeated_loops_each_substitute_independently() {
|
||||
let s = script();
|
||||
@@ -184,7 +154,6 @@ fn nested_and_repeated_loops_each_substitute_independently() {
|
||||
// Each of the 2 outer pairs runs the full inner loop (30) plus its own value.
|
||||
assert_eq!(out, 30 + 30 + 1 + 2);
|
||||
|
||||
// Entering the same loop a second time must work identically.
|
||||
let twice: i64 = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -197,15 +166,8 @@ fn nested_and_repeated_loops_each_substitute_independently() {
|
||||
assert_eq!(twice, 4);
|
||||
}
|
||||
|
||||
/// **5.0's long-string NESTING, restored** — the second dialect divergence in this file's family.
|
||||
///
|
||||
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, which is what 1.12.1 ships. 5.1 kept the machinery
|
||||
/// (`llex.c` `read_long_string`'s `cont` counter, guarded by `LUA_COMPAT_LSTR == 2`) and put an
|
||||
/// advisory error in front of it — *"nesting of [[...]] is deprecated"* — which is a 5.1 opinion
|
||||
/// about a dialect we are not targeting. Two corpus addons stopped LOADING on it.
|
||||
///
|
||||
/// The fix is Lua's own documented switch, `LUA_COMPAT_LSTR` 1 -> 2 in our vendored `luaconf.h`,
|
||||
/// not a patch to the lexer: the code path was already there and already correct.
|
||||
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, as 1.12.1 ships it; the vendored `luaconf.h` sets
|
||||
/// `LUA_COMPAT_LSTR` to 2, which enables 5.1's own nesting path in `read_long_string`.
|
||||
#[test]
|
||||
fn a_nested_long_string_parses_as_lua_50_does() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -214,27 +176,19 @@ fn a_nested_long_string_parses_as_lua_50_does() {
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return NESTED_OK").unwrap(), 1);
|
||||
|
||||
// And a nested long STRING keeps its inner brackets, 5.0's own semantics: the outer runs to
|
||||
// the matching close, so the inner `]]` does not terminate it.
|
||||
// A nested long string keeps its inner brackets: the inner `]]` does not close it.
|
||||
let v = s
|
||||
.eval::<String>("return [[a [[b]] c]]")
|
||||
.expect("nested long string must parse");
|
||||
assert_eq!(v, "a [[b]] c");
|
||||
}
|
||||
|
||||
/// **5.0's constructor compat-semicolon, restored** — the third dialect divergence (decision 1315).
|
||||
///
|
||||
/// Lua 5.0's `constructor()` opens its field loop with `testnext(ls, ';') /* compatibility
|
||||
/// only */` — a Lua 4.0 leftover (4.0 separated a constructor's list part from its record part
|
||||
/// with `;`) that silently eats ONE extra `;` per separator position inside a table constructor.
|
||||
/// 5.1 deleted the line. AtlasLoot's `ButtonRegistry.lua` writes `Back_Title = AL["Factions"];;`
|
||||
/// twenty times; the real client loads it (director-verified on the live 1.12.1 client), stock
|
||||
/// 5.1 answered "unexpected symbol near ';'" and the addon's registry global never existed (B266).
|
||||
/// Lua 5.0's `constructor()` opens its field loop with `testnext(ls, ';')`, eating one extra `;`
|
||||
/// per separator position; 5.1 deleted the line, and AtlasLoot's `ButtonRegistry.lua` writes `;;`.
|
||||
#[test]
|
||||
fn a_double_semicolon_inside_a_constructor_parses_as_lua_50_does() {
|
||||
let s = script();
|
||||
|
||||
// The exact corpus shape: a record field, its `;` separator, and the compat extra.
|
||||
let out: String = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -248,19 +202,15 @@ fn a_double_semicolon_inside_a_constructor_parses_as_lua_50_does() {
|
||||
.expect("the ButtonRegistry shape must parse");
|
||||
assert_eq!(out, "Factions");
|
||||
|
||||
// The compat skip runs at loop TOP, so a lone `;` before `}` — legal 5.0, an empty record
|
||||
// part in 4.0 terms — is eaten the same way.
|
||||
// The skip runs at the loop's top, so the lone `;` in `{;}` is eaten too.
|
||||
assert_eq!(s.eval::<i64>("local t = {;} return 1").unwrap(), 1);
|
||||
|
||||
// List context too: the separator may be `;` and the extra one is still absorbed.
|
||||
let n: i64 = s.eval("local t = { 1;; 2 } return t[1] + t[2]").unwrap();
|
||||
assert_eq!(n, 3);
|
||||
}
|
||||
|
||||
/// **The restoration is exactly one semicolon wide, and constructor-only.** Both halves of the
|
||||
/// boundary are the client's: 5.0 eats ONE extra `;` per position (a third dies in `listfield`),
|
||||
/// and statement-level `;;` is rejected by 5.0 and 5.1 alike — confirmed by the client's own parser
|
||||
/// (`luaL_loadbuffer` → `chunk 0x6fcc90`).
|
||||
/// A third `;` dies in `listfield`, and statement-level `;;` is rejected by the client's own parser
|
||||
/// too (`luaL_loadbuffer`, `chunk 0x6fcc90`).
|
||||
#[test]
|
||||
fn the_compat_semicolon_is_one_wide_and_statement_level_stays_rejected() {
|
||||
let s = script();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! The Help window's seven engine globals ([`crate::script::gm_ticket`], decision 1673).
|
||||
//!
|
||||
//! Two claims carry the whole feature and each has a test named after it: the category list is
|
||||
//! **flat pairs consumed by Lua 5.0 varargs** (that is what `HelpFrameGM_UpdateCategories` reads),
|
||||
//! and the four payload-free verbs **count** rather than latch (that is what keeps the window's
|
||||
//! 10-minute ticket poll alive).
|
||||
//! The Help window's seven engine globals ([`crate::script::gm_ticket`]).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{GmTicketIntent, GmTicketWrite, UiScript};
|
||||
@@ -19,9 +14,7 @@ fn with_categories() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// **`GetGMTicketCategories()` returns a FLAT (id, name) vararg list**, which the shipped window
|
||||
/// walks as `arg[i]` / `arg[i+1]` pairs with `arg.n` — the Lua 5.0 convention. A table here, or a
|
||||
/// list of names alone, would leave every category button unlabelled or unclickable.
|
||||
/// The stock window walks it as `arg[i]`, `arg[i+1]` pairs up to `arg.n`.
|
||||
#[test]
|
||||
fn the_categories_come_back_as_a_flat_id_name_vararg_list() {
|
||||
let s = with_categories();
|
||||
@@ -37,17 +30,14 @@ fn the_categories_come_back_as_a_flat_id_name_vararg_list() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((id1, name1.as_str()), (1, "Stuck"));
|
||||
// The THIRD pair is at 5/6 — pairs, not a parallel pair of lists.
|
||||
assert_eq!((id3, name3.as_str()), (3, "Guild"));
|
||||
}
|
||||
|
||||
/// **The ids are the DBC's, not indices.** `HelpFrameGM_UpdateCategories` stores `arg[index]` as
|
||||
/// both `button.key` (an index into `HELPFRAME_FRAMES`) and `button.ticketType` (what goes on the
|
||||
/// wire), so a renumbered list would misfile every ticket under the wrong heading.
|
||||
/// `HelpFrameGM_UpdateCategories` stores the id as `button.key` and as the wire `ticketType`.
|
||||
#[test]
|
||||
fn the_ids_are_the_dbc_ids_not_list_positions() {
|
||||
let mut s = script();
|
||||
// A deliberately gappy, unsorted catalog — nothing may re-index it.
|
||||
// Gappy and unsorted on purpose.
|
||||
s.set_gm_ticket_categories(vec![(10, "Character".into()), (4, "Item".into())]);
|
||||
let (a, b): (i64, i64) = s
|
||||
.eval("local f = function(...) return arg[1], arg[3] end return f(GetGMTicketCategories())")
|
||||
@@ -55,8 +45,7 @@ fn the_ids_are_the_dbc_ids_not_list_positions() {
|
||||
assert_eq!((a, b), (10, 4), "ids and order pass through untouched");
|
||||
}
|
||||
|
||||
/// No catalog is not an error: the window paints no category rows and cannot raise. This is the
|
||||
/// bare-XML harness, and a run with no client data.
|
||||
/// The bare-XML harness and a run with no client data have no catalog.
|
||||
#[test]
|
||||
fn an_absent_catalog_returns_nothing_rather_than_erroring() {
|
||||
let s = script();
|
||||
@@ -66,9 +55,7 @@ fn an_absent_catalog_returns_nothing_rather_than_erroring() {
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
/// **The verbs queue, they do not latch.** Two `GetGMTicket()` calls are two packets, because the
|
||||
/// ticket toast re-polls every 10 minutes from `TicketStatus_OnUpdate` — a latch would collapse the
|
||||
/// second poll into the first and the window would look hung.
|
||||
/// Two `GetGMTicket()` calls are two packets: `TicketStatus_OnUpdate` re-polls every 10 minutes.
|
||||
#[test]
|
||||
fn the_payload_free_verbs_queue_rather_than_latch() {
|
||||
let mut s = script();
|
||||
@@ -84,15 +71,11 @@ fn the_payload_free_verbs_queue_rather_than_latch() {
|
||||
]
|
||||
);
|
||||
assert_eq!(s.take_stuck_casts(), 3, "Stuck is not a ticket verb");
|
||||
// Drained means drained — the next frame must not re-send.
|
||||
assert!(s.take_gm_ticket_intents().is_empty());
|
||||
assert_eq!(s.take_stuck_casts(), 0);
|
||||
}
|
||||
|
||||
/// **Call order is wire order.** A chunk that abandons and then re-asks must reach the server in
|
||||
/// that order — reversed, the get answers with the state *before* the delete and the window is
|
||||
/// told it still has the ticket it just abandoned. Per-verb drains cannot express this, which is
|
||||
/// why there is one queue (decision 1673).
|
||||
/// Call order is wire order: reversed, the get would answer with the ticket just deleted.
|
||||
#[test]
|
||||
fn the_queue_preserves_call_order_across_different_verbs() {
|
||||
let mut s = script();
|
||||
@@ -103,15 +86,13 @@ fn the_queue_preserves_call_order_across_different_verbs() {
|
||||
"delete first, exactly as Lua called them"
|
||||
);
|
||||
|
||||
// And the other way round, so the test cannot pass on a fixed per-verb ordering that merely
|
||||
// happens to match one of the two cases.
|
||||
// The other way round, so a fixed per-verb order cannot pass.
|
||||
s.run("GetGMTicket() DeleteGMTicket()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_gm_ticket_intents(),
|
||||
vec![GmTicketIntent::Ask, GmTicketIntent::Delete]
|
||||
);
|
||||
|
||||
// Writes interleave with the bare verbs on the same queue.
|
||||
s.run("NewGMTicket(1, \"a\") GetGMTicket()").unwrap();
|
||||
assert!(matches!(
|
||||
s.take_gm_ticket_intents().as_slice(),
|
||||
@@ -119,9 +100,7 @@ fn the_queue_preserves_call_order_across_different_verbs() {
|
||||
));
|
||||
}
|
||||
|
||||
/// **The window's verb survives to the app.** `NewGMTicket` and `UpdateGMTicket` share a signature
|
||||
/// but not an opcode, and the shipped window picks between them from its own `hasTicket` flag —
|
||||
/// so the choice is carried, never re-derived from what the client believes.
|
||||
/// Same signature, different opcodes; the stock window picks one from its own `hasTicket` flag.
|
||||
#[test]
|
||||
fn new_and_update_are_distinguishable_and_keep_their_order() {
|
||||
let mut s = script();
|
||||
@@ -144,10 +123,9 @@ fn new_and_update_are_distinguishable_and_keep_their_order() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Era arity is `(number, string)` — the reference's own usage string is
|
||||
/// `Usage: UpdateGMTicket(type, text)`. An empty ticket is the *window's* refusal to make, not
|
||||
/// ours: the binding queues whatever it is handed, and the engine's own emptiness check sits
|
||||
/// upstream of the send.
|
||||
/// The reference's usage string is `Usage: UpdateGMTicket(type, text)`. The binding queues any
|
||||
/// text; the reference refuses an empty one before the send (`ERR_TICKET_NO_TEXT`, `0x5ef808`,
|
||||
/// `0x5efae7`).
|
||||
#[test]
|
||||
fn an_empty_ticket_body_is_queued_not_swallowed() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
//! Per-handler cost attribution (decision 1395) — the instrument that answers *which* handler.
|
||||
//!
|
||||
//! These assert on **names, call counts, and the self/total relation**, never on a duration
|
||||
//! (0735): "this handler cost 2 ms" is not a fact a test can own. The one relational assertion
|
||||
//! (`self < total` under nesting) carries its own exact control — the same shape with nothing
|
||||
//! nested inside it must report `self == total` to the last nanosecond, which is what proves the
|
||||
//! difference came from the subtraction rather than from the clock.
|
||||
//! Per-handler cost attribution. The tests assert names, call counts and the self/total relation,
|
||||
//! never a duration; `self == total` with nothing nested is the control for `self < total`.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{HandlerRow, ScriptValue, UiScript};
|
||||
|
||||
/// The row for `frame:script`, or `None`.
|
||||
fn row(rows: &[HandlerRow], frame: &str, script: &str) -> Option<HandlerRow> {
|
||||
rows.iter()
|
||||
.find(|r| r.frame == frame && r.script == script)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Recording, with no periodic report — the tests read the window directly.
|
||||
/// Recording, with no periodic report: the tests read the window directly.
|
||||
fn armed() -> UiScript {
|
||||
let s = script();
|
||||
s.profile_handlers(true, 0.0);
|
||||
@@ -69,8 +63,7 @@ fn every_fired_handler_is_attributed_to_its_own_frame_and_script() {
|
||||
|
||||
#[test]
|
||||
fn a_handler_with_nothing_nested_inside_it_is_all_self_time() {
|
||||
// The control for the nesting test below: with no nested fire, the subtraction is by zero and
|
||||
// the two numbers must be *identical*, not merely close.
|
||||
// The control for the nesting test: nothing nested subtracts zero, so the two are identical.
|
||||
let mut s = armed();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -122,14 +115,13 @@ fn a_handler_that_fires_another_is_not_charged_for_its_child() {
|
||||
outer.self_us < outer.total_us,
|
||||
"the child's cost must come off the parent's self time: {outer:#?}"
|
||||
);
|
||||
// And the nested row must be the one holding it — the whole point is naming Inner, not Outer.
|
||||
assert!(
|
||||
inner.self_us > 0.0,
|
||||
"the child's own cost lands on the child: {inner:#?}"
|
||||
);
|
||||
|
||||
// A second tick must behave like the first: a leaked stack level would start charging Outer's
|
||||
// child time to a phantom parent and the relation above would quietly stop holding.
|
||||
// A second tick behaves like the first: a leaked stack level would charge the child's time to
|
||||
// a phantom parent.
|
||||
s.tick(0.016);
|
||||
let rows = s.handler_profile();
|
||||
let outer = row(&rows, "Outer", "OnUpdate").expect("outer");
|
||||
@@ -140,8 +132,7 @@ fn a_handler_that_fires_another_is_not_charged_for_its_child() {
|
||||
|
||||
#[test]
|
||||
fn an_anonymous_frames_handler_is_labelled_by_where_it_was_written() {
|
||||
// `CreateFrame("Frame")` + OnUpdate with no name is the addon timer idiom, so this is the
|
||||
// common case, not the exotic one — and `#4127` would be an unusable row.
|
||||
// An unnamed frame's OnUpdate is the addon timer idiom, and `#4127` would be a useless label.
|
||||
let mut s = armed();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -164,9 +155,8 @@ fn an_anonymous_frames_handler_is_labelled_by_where_it_was_written() {
|
||||
|
||||
#[test]
|
||||
fn an_unarmed_vm_records_nothing() {
|
||||
// The off-state is the shipped state, so it gets a guard of its own: a VM that was never armed
|
||||
// must report an empty profile even while another VM in this process is recording (the gate is
|
||||
// process-wide, the state is per-VM — see `handler_prof::ARMED`).
|
||||
// An unarmed VM records nothing while another records: the gate (`handler_prof::ARMED`) is
|
||||
// process-wide, the state per VM.
|
||||
let _recording = armed();
|
||||
let mut s = script();
|
||||
s.run(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Input / hit-testing (decision 0068; spec-faithful, not byte-pinned).
|
||||
//! Mouse input: hit order, hover, clicks, double clicks and the release capture.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
@@ -38,11 +38,8 @@ fn enable_mouse_gates_hit_testing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hit sweep's key, all three terms: strata, then level, then — at a tie — the **earlier-linked**
|
||||
/// frame, NOT the later one that draws on top (decision 1816; `0x764aa0`'s strict `ja` appending
|
||||
/// equal keys and `0x7660d0` sweeping from index 0). This test used to assert the opposite at the
|
||||
/// tie and was named for it — draw order and hit order agree on strata and level and disagree on
|
||||
/// exactly this.
|
||||
/// At a strata and level tie the earlier-linked frame takes the hit, though the later one draws on
|
||||
/// top: `0x764aa0` appends equal keys after (strict `ja`) and `0x7660d0` sweeps from index 0.
|
||||
#[test]
|
||||
fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
|
||||
let mut s = script();
|
||||
@@ -61,24 +58,21 @@ fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Same strata/level: A was linked FIRST, so the sweep reaches it first and it captures —
|
||||
// even though B, linked later, draws on top of it.
|
||||
// Same strata and level: A, linked first, captures though B draws on top.
|
||||
s.mouse_move(400.0, 300.0);
|
||||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||||
|
||||
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
|
||||
s.mouse_move(-10.0, -10.0);
|
||||
|
||||
// Raise A above B by strata (no rect change ⇒ no re-resolve needed): strata outranks the tie,
|
||||
// and A keeps the point for the stronger reason.
|
||||
// Strata outranks the tie; no rect changes, so no re-resolve.
|
||||
s.run("A:SetFrameStrata('DIALOG')").unwrap();
|
||||
s.mouse_move(400.0, 300.0);
|
||||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||||
|
||||
s.mouse_move(-10.0, -10.0);
|
||||
|
||||
// Put B in the same (DIALOG) strata but a higher frame level: level outranks link order, so
|
||||
// B takes it back — the tie rule only decides frames that are equal on both.
|
||||
// Same strata, higher level: level outranks link order, so B captures.
|
||||
|
||||
s.run("B:SetFrameStrata('DIALOG'); B:SetFrameLevel(10)")
|
||||
.unwrap();
|
||||
@@ -137,13 +131,11 @@ fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere()
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Press + release both on A ⇒ OnClick on A (button arg == "LeftButton").
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks_a").unwrap(), 1);
|
||||
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "LeftButton");
|
||||
|
||||
// Press on A, release on B ⇒ no OnClick on either frame.
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||||
s.mouse_button(600.0, 300.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
@@ -225,8 +217,8 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() {
|
||||
fn hit_rect_insets_shrink_the_mouse_rect_only() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// A 100×100 frame at the origin with the micro-button shape: an 18-unit dead header at the
|
||||
// top, and 5 off each other side.
|
||||
// A 100×100 frame at the origin; the top inset is the micro buttons' 18
|
||||
// (`MainMenuBarMicroButtons.xml:9`), with 5 off each other side.
|
||||
s.run(
|
||||
r#"
|
||||
entered = false
|
||||
@@ -241,24 +233,20 @@ fn hit_rect_insets_shrink_the_mouse_rect_only() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Geometry is untouched — insets move the MOUSE rect, never the frame.
|
||||
assert_eq!(s.eval::<f64>("return A:GetHeight()").unwrap(), 100.0);
|
||||
assert_eq!(s.eval::<f64>("return A:GetTop()").unwrap(), 100.0);
|
||||
|
||||
// Inside the frame but inside the dead header ⇒ no capture (the case the ref's top=18 buys:
|
||||
// the empty band above a micro button's art must not eat the click).
|
||||
assert!(
|
||||
s.hit_test(50.0, 90.0).is_none(),
|
||||
"a point in the inset header is outside the hit rect"
|
||||
);
|
||||
assert!(s.mouse_move(50.0, 90.0).is_none());
|
||||
assert!(!s.eval::<bool>("return entered").unwrap());
|
||||
// …and each of the other three insets bites too.
|
||||
assert!(s.hit_test(2.0, 50.0).is_none(), "left inset");
|
||||
assert!(s.hit_test(98.0, 50.0).is_none(), "right inset");
|
||||
assert!(s.hit_test(50.0, 2.0).is_none(), "bottom inset");
|
||||
|
||||
// Just below the header, still inside the shrunken rect ⇒ captures.
|
||||
// Just below the header, inside the shrunken rect: captures.
|
||||
assert!(s.hit_test(50.0, 80.0).is_some());
|
||||
assert!(s.mouse_move(50.0, 80.0).is_some());
|
||||
assert!(s.eval::<bool>("return entered").unwrap());
|
||||
@@ -277,13 +265,6 @@ fn hit_rect_insets_default_to_zero() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `EnableMouseWheel`/`IsMouseWheelEnabled` round-trip, and the two kinds born wheel-enabled
|
||||
/// (decision 1198).
|
||||
///
|
||||
/// The flag is real and settable; **the dispatch is deliberately not gated on it yet** — see
|
||||
/// `object::frame_state`'s note for the 44 shipped `OnMouseWheel` sites that declare no
|
||||
/// `enableMouseWheel` and the concrete condition for flipping it. This test pins the flag's own
|
||||
/// behaviour so that flip is a one-line change with a test already standing behind it.
|
||||
#[test]
|
||||
fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
|
||||
let s = script();
|
||||
@@ -296,12 +277,11 @@ fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A plain frame is born wheel-deaf, like WoW's own default.
|
||||
// A plain frame is born wheel-disabled, as in the reference; the two scrolling kinds' own
|
||||
// constructors enable it.
|
||||
assert!(!s
|
||||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
// ...and the two kinds whose ctor takes the wheel are born enabled, the same by-construction
|
||||
// argument `mouse_enabled` already makes for a Button.
|
||||
assert!(s
|
||||
.eval::<bool>("return Scroll:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
@@ -323,14 +303,8 @@ fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A closed-vocabulary attribute survives stray whitespace (decision 1204).
|
||||
///
|
||||
/// The corpus case: `zBar.xml:146` — a shipped, working 1.12 addon — declares
|
||||
/// `frameStrata="BACKGROUND "` with a trailing space. The real client took it; we refused, and the
|
||||
/// refusal took the frame's whole `<Frames>` subtree with it, so the addon never loaded at all.
|
||||
///
|
||||
/// The three parsers are covered together because the next one to meet a stray space should not
|
||||
/// need its own bug report.
|
||||
/// The reference takes a closed-vocabulary value with stray whitespace: the zBar addon ships
|
||||
/// `frameStrata="BACKGROUND "` (`zBar.xml:146`). Frame kind, strata and draw layer all trim.
|
||||
#[test]
|
||||
fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
|
||||
let s = script();
|
||||
@@ -348,24 +322,19 @@ fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
|
||||
"BACKGROUND",
|
||||
"the frame took the strata, and reports it in the canonical spelling"
|
||||
);
|
||||
// ...and the padded frame KIND resolved too — an unknown kind raises from CreateFrame, so
|
||||
// reaching this line at all is the assertion.
|
||||
// The padded kind resolved: an unknown kind would have raised from `CreateFrame`.
|
||||
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
|
||||
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
|
||||
}
|
||||
|
||||
// ── OnDoubleClick — the corpus's biggest script gap (250 sites / 85 addons) ───────────────────
|
||||
// ── OnDoubleClick ──
|
||||
//
|
||||
// Every rule below is byte-verified: the interval is a hardcoded **300 ms**
|
||||
// (`0x77937b cmp ecx, 0x12c`), the fire site is the mouse-**UP** dispatcher `0x7792d0` alone, the
|
||||
// double leg **replaces** the second `OnClick` (`0x77939d jmp` past `call [edx+0x94]`), a completed
|
||||
// double **zeroes** the stamp so clicks pair up, the detector is armed only when the frame carries
|
||||
// an `OnDoubleClick` script (`[+0x4d4] != 0`), and it carries **no button identity** — what
|
||||
// normally confines it to the left button is `RegisterForClicks`, not the detector.
|
||||
// The interval is a fixed 300 ms (`0x77937b`, `cmp ecx, 0x12c`) and the only fire site is the
|
||||
// mouse-up dispatcher `0x7792d0`. The double replaces the second `OnClick` (`0x77939d` jumps past
|
||||
// it), a completed double zeroes the stamp so clicks pair up, the detector arms only when the frame
|
||||
// has an `OnDoubleClick` script (`[+0x4d4] != 0`), and it keys on no button: `RegisterForClicks`
|
||||
// is what confines it to the left button.
|
||||
|
||||
/// Two fast clicks fire `OnDoubleClick(self, button)` — and the second `OnClick` does **not** fire,
|
||||
/// because the two legs are exclusive. (This was the interim implementation's mistake: it fired
|
||||
/// both, on the press edge, at 500 ms. All three were corrected at the bytes.)
|
||||
#[test]
|
||||
fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
|
||||
let mut s = script();
|
||||
@@ -382,7 +351,6 @@ fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Click one. Note the press alone does nothing: the fire site is the mouse-UP dispatcher.
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return clicks").unwrap(),
|
||||
@@ -419,10 +387,8 @@ fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The gate that keeps rapid clicking from breaking every button in the UI.** The binary's chain
|
||||
/// tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script takes the single leg every
|
||||
/// time. Without that test, the exclusivity above would silently swallow every second `OnClick` on
|
||||
/// every frame in the game.
|
||||
/// The detector tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script always takes
|
||||
/// the single-click leg.
|
||||
#[test]
|
||||
fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
|
||||
let mut s = script();
|
||||
@@ -449,9 +415,6 @@ fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The boundary: too slow, a different frame, and the PAIRING rule (a completed double zeroes the
|
||||
/// stamp, so four rapid clicks read Click · Double · Click · Double — there is no triple-click and
|
||||
/// no run of doubles).
|
||||
#[test]
|
||||
fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||||
let mut s = script();
|
||||
@@ -476,14 +439,14 @@ fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||||
s.mouse_button(x, 50.0, "LeftButton", false);
|
||||
};
|
||||
|
||||
// (1) TOO SLOW — 0.35 s > 300 ms, so the second click is a fresh first half.
|
||||
// (1) Too slow: 0.35 s is past the 300 ms, so the second click starts afresh.
|
||||
click(&mut s, 50.0);
|
||||
s.tick(0.35);
|
||||
click(&mut s, 50.0);
|
||||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
|
||||
// (2) A DIFFERENT FRAME — fast, but the timestamp lives on the WIDGET, so B has its own.
|
||||
// (2) A different frame: the stamp lives on the widget, so B has its own.
|
||||
s.run("clicks, doubles = 0, 0").unwrap();
|
||||
s.tick(1.0);
|
||||
click(&mut s, 50.0);
|
||||
@@ -491,7 +454,7 @@ fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
|
||||
// (3) PAIRING — four rapid clicks on one frame are Click · Double · Click · Double.
|
||||
// (3) Pairing: four rapid clicks on one frame are click, double, click, double.
|
||||
s.run("clicks, doubles = 0, 0").unwrap();
|
||||
s.tick(1.0);
|
||||
for _ in 0..4 {
|
||||
@@ -508,11 +471,8 @@ fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The detector carries no button identity** — the surprising half of the law, and the one an
|
||||
/// implementation keyed by mouse button would get wrong. `[CButton+0x334]` is a bare timestamp, so
|
||||
/// a widget registered for two `…Up` types completes a double click across them, with `arg1` = the
|
||||
/// button of the SECOND click. A stock button never sees this only because `RegisterForClicks`
|
||||
/// defaults to `{"LeftButtonUp"}`.
|
||||
/// `[CButton+0x334]` is a bare timestamp, so a button registered for two `…Up` types completes a
|
||||
/// double click across them, and `arg1` is the second click's button.
|
||||
#[test]
|
||||
fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
|
||||
let mut s = script();
|
||||
@@ -542,10 +502,9 @@ fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Nothing clears a half-finished double click** — not hide, not disable, not the cursor leaving
|
||||
/// the window. `[+0x334]` has three writers image-wide and none of them is any of those, so this
|
||||
/// pins the *absence* of the tidy-up that looks like it belongs in
|
||||
/// [`crate::script::UiScript::pointer_left_window`].
|
||||
/// Nothing clears a half-finished double click, not hide, disable or the cursor leaving the
|
||||
/// window: none of `[+0x334]`'s three writers is one of those, so
|
||||
/// [`crate::script::UiScript::pointer_left_window`] must not reset it.
|
||||
#[test]
|
||||
fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
|
||||
let mut s = script();
|
||||
@@ -573,9 +532,7 @@ fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The load-bearing regression guard for the whole job: accepting `OnDoubleClick` in
|
||||
/// `SCRIPT_KINDS` without wiring the detector would make this test's `SetScript` succeed and its
|
||||
/// handler never run — the silent-capability trap. Asserting the *fire* is what makes the row real.
|
||||
/// A name in `SCRIPT_KINDS` must have something that fires it, so this asserts the fire.
|
||||
#[test]
|
||||
fn set_script_on_double_click_is_accepted_because_something_fires_it() {
|
||||
let mut s = script();
|
||||
@@ -602,30 +559,16 @@ fn set_script_on_double_click_is_accepted_because_something_fires_it() {
|
||||
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
|
||||
}
|
||||
|
||||
/// The script names that deliberately still raise, and why each is out (the reasons live at
|
||||
/// `object::events_regions::set_script`). This is the other half of the rule "a name is accepted
|
||||
/// only once something fires it": a future widening has to delete a line here, which is exactly
|
||||
/// the moment to check something fires it.
|
||||
/// Script names nothing fires still raise; a name leaves this list only when something fires it.
|
||||
#[test]
|
||||
fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
let s = script();
|
||||
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
|
||||
.unwrap();
|
||||
for name in [
|
||||
// `OnKeyDown`/`OnKeyUp`/`OnChar` are NOT in this list any more: decision 1319 built the
|
||||
// delivery walk, so they are fired and therefore accepted — the rule this test enforces,
|
||||
// applied in the other direction. Their law lives in `script::tests::keyboard`.
|
||||
//
|
||||
// `OnCursorChanged` left this list with decision 2141: the tick's caret flush fires it
|
||||
// (`editbox::drain_cursor_changed`), which is the rule applied in the other direction
|
||||
// again — its law lives in `script::editbox::tests`.
|
||||
//
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
// A 2.0 secure-frame slot; no 1.12 resolver has it.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites. (`OnUpdateModel` and
|
||||
// `OnAnimFinished` left this list with decision 2007: the tick's model pass fires
|
||||
// them — `script::tests::model_clock`. `OnHorizontalScroll` left it with the ScrollFrame's
|
||||
// horizontal offset pair, which fires it — `script::tests::scrollframe`.)
|
||||
// Real 1.12 slots that benilla does not fire.
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnInputLanguageChanged",
|
||||
@@ -640,10 +583,8 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover-hide law: hiding the hovered frame fires its `OnLeave` synchronously, BEFORE its own
|
||||
/// `OnHide` (`0x764ba0`'s kind-2 tail runs mid-hide, the leave at `0x764cce`; OnHide fires at the
|
||||
/// hide body's tail) — and an ancestor's `Hide()` reaches the hovered descendant through the
|
||||
/// cascade.
|
||||
/// Hiding the hovered frame fires its `OnLeave` inside the hide, before its `OnHide` (`0x764ba0`'s
|
||||
/// removal tail, the leave at `0x764cce`), and an ancestor's `Hide()` reaches it by the cascade.
|
||||
#[test]
|
||||
fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
|
||||
let mut s = script();
|
||||
@@ -662,7 +603,7 @@ fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_move(150.0, 150.0); // hover the slot
|
||||
s.run("Win:Hide()").unwrap(); // the ANCESTOR hides — the cascade reaches the slot
|
||||
s.run("Win:Hide()").unwrap(); // the ancestor hides; the cascade reaches the slot
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"leave,hide",
|
||||
@@ -671,9 +612,8 @@ fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The removal tail's own DISABLED case: `0x764cce` dispatches `[edx+0x50]` **virtually**, so a
|
||||
/// Button lands in `0x7794e0` and the guard swallows the leave there too. The `OnHide` half is the
|
||||
/// control — that one is not the hover notify and fires either way.
|
||||
/// `0x764cce` dispatches the leave virtually (`[edx+0x50]`), so a disabled Button's own guard
|
||||
/// (`0x7794e0`) swallows it; `OnHide` is not the hover notify and fires either way.
|
||||
#[test]
|
||||
fn hiding_a_hovered_disabled_button_fires_onhide_but_no_onleave() {
|
||||
let mut s = script();
|
||||
@@ -700,8 +640,7 @@ fn hiding_a_hovered_disabled_button_fires_onhide_but_no_onleave() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The negative: hiding a frame the cursor is NOT over fires no OnLeave (the reference's removal
|
||||
/// tail acts only when the removed frame IS the cached hover).
|
||||
/// The reference's removal tail acts only when the removed frame is the cached hover.
|
||||
#[test]
|
||||
fn hiding_an_unhovered_frame_fires_no_onleave() {
|
||||
let mut s = script();
|
||||
@@ -722,10 +661,9 @@ fn hiding_an_unhovered_frame_fires_no_onleave() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The re-pick (`[root+0x1100]` + the pump tail `0x7660d0` on the SAVED event): after the hovered
|
||||
/// frame hides, the next tick re-hovers whatever is now topmost at the unchanged cursor — OnEnter
|
||||
/// with no physical mouse move. And the symmetric insert law: a frame SHOWN under a stationary
|
||||
/// cursor gets hovered on the next tick too (`0x764b8d`).
|
||||
/// After the hovered frame hides, the next tick re-hovers what is topmost at the unmoved cursor
|
||||
/// (`[root+0x1100]`, the pump tail `0x7660d0` on the saved event); a frame shown under the cursor
|
||||
/// is hovered the same way (`0x764b8d`).
|
||||
#[test]
|
||||
fn the_repick_re_hovers_without_a_mouse_move() {
|
||||
let mut s = script();
|
||||
@@ -748,14 +686,13 @@ fn the_repick_re_hovers_without_a_mouse_move() {
|
||||
s.resolve();
|
||||
s.mouse_move(150.0, 150.0); // hovers Over (topmost)
|
||||
s.run("Over:Hide()").unwrap();
|
||||
s.tick(0.016); // the pump's re-pick — cursor has not moved
|
||||
s.tick(0.016); // the pump's re-pick; the cursor has not moved
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(entered, ',')")
|
||||
.unwrap(),
|
||||
"over,under",
|
||||
"the frame beneath is entered on the next tick with no mouse move"
|
||||
);
|
||||
// The insert half: re-showing the top frame under the stationary cursor re-hovers it.
|
||||
s.run("Over:Show()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
@@ -767,9 +704,7 @@ fn the_repick_re_hovers_without_a_mouse_move() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The mouse-UP dispatch law (decision 1599) — byte-verified at `0x766420`
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The mouse-up dispatch (`0x766420`) ──
|
||||
|
||||
/// Two side-by-side frames, each recording every `OnMouseDown`/`OnMouseUp` it gets.
|
||||
fn two_frames() -> crate::script::UiScript {
|
||||
@@ -798,14 +733,9 @@ fn log_of(s: &mut crate::script::UiScript) -> String {
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap()
|
||||
}
|
||||
|
||||
/// **The release goes to the frame that took the press, wherever the cursor has got to.** The
|
||||
/// client's `0x766420` snapshots `[mgr+0x80]` — the capture — at entry (`0x76642b`) and dispatches
|
||||
/// through it alone; `[mgr+0x7c]`, the hover frame, is never read in the whole function. So B, which
|
||||
/// the cursor is over at release, gets nothing on that edge.
|
||||
///
|
||||
/// The concrete thing this buys: a chat window's 16×16 resize grip is pulled out from under the
|
||||
/// cursor the instant the size clamps at a resize bound, and its `OnMouseUp → FCF_StopResize` has
|
||||
/// to run anyway or the sizing drag is held for the rest of the session.
|
||||
/// `0x766420` reads the capture `[mgr+0x80]` at entry (`0x76642b`) and never the hover
|
||||
/// `[mgr+0x7c]`, so the release reaches the pressed frame wherever the cursor is; a chat resize
|
||||
/// grip's `FCF_StopResize` relies on it (`FloatingChatFrame.xml:292`).
|
||||
#[test]
|
||||
fn the_release_goes_to_the_frame_that_took_the_press() {
|
||||
let mut s = two_frames();
|
||||
@@ -819,13 +749,8 @@ fn the_release_goes_to_the_frame_that_took_the_press() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A press that captured nothing fires nothing on release** — and this is the half that is easy
|
||||
/// to get wrong, because the DOWN handler *does* have a hover fallback (`root+0x80` else
|
||||
/// `root+0x7c`) and the UP handler does **not** share it: `0x766498 test ebx,ebx / je 0x7664cd`
|
||||
/// returns with no virtual call at all.
|
||||
///
|
||||
/// This shipped briefly as `captured.or(hit)` — a press over open space then delivered its release
|
||||
/// to whatever the cursor had wandered onto, which is a frame that never saw the press.
|
||||
/// The down handler falls back to the hover (`root+0x80` else `root+0x7c`), the up handler does
|
||||
/// not: with no capture, `0x766498` jumps to `0x7664cd` and dispatches nothing.
|
||||
#[test]
|
||||
fn a_press_that_captured_nothing_delivers_no_release() {
|
||||
let mut s = two_frames();
|
||||
@@ -839,7 +764,6 @@ fn a_press_that_captured_nothing_delivers_no_release() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The ordinary case still behaves: press and release on the same frame is down-then-up on it.
|
||||
#[test]
|
||||
fn a_press_and_release_on_one_frame_is_unchanged() {
|
||||
let mut s = two_frames();
|
||||
@@ -848,15 +772,13 @@ fn a_press_and_release_on_one_frame_is_unchanged() {
|
||||
assert_eq!(log_of(&mut s), "A:down A:up");
|
||||
}
|
||||
|
||||
/// The capture is **per button**, so a right-button release cannot be answered by a left-button
|
||||
/// press's capture — `Model::mouse_down_on` is keyed by button name, which is what makes this fall
|
||||
/// out rather than needing its own arm.
|
||||
/// `Model::mouse_down_on` is keyed by button name, so the capture is per button.
|
||||
#[test]
|
||||
fn the_capture_is_per_button() {
|
||||
let mut s = two_frames();
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true); // A holds the LEFT button
|
||||
s.mouse_button(200.0, 300.0, "LeftButton", true); // A holds the left button
|
||||
s.mouse_move(500.0, 300.0);
|
||||
s.mouse_button(500.0, 300.0, "RightButton", false); // a RIGHT release, never pressed
|
||||
s.mouse_button(500.0, 300.0, "RightButton", false); // a right release, never pressed
|
||||
assert_eq!(
|
||||
log_of(&mut s),
|
||||
"A:down",
|
||||
@@ -864,16 +786,9 @@ fn the_capture_is_per_button() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A DISABLED Button runs neither `<OnEnter>` nor `<OnLeave>`** — `CSimpleButton::OnEnter
|
||||
/// 0x779490` / `OnLeave 0x7794e0` open `mov eax,[esi+0x328]; test eax,eax; je`, branching past the
|
||||
/// base notify that owns both script slots (`0x76b6a0`/`0x76b6f0`). It
|
||||
/// still TAKES the hover, because the walk reassigns `[root+0x7c]` before it fires the notify.
|
||||
///
|
||||
/// The customer is every 1.12 addon that wrote `if this:IsEnabled() then` at the top of an
|
||||
/// `<OnEnter>` and expected it to be false: `IsEnabled()` is the NUMBER `1`/`0`, always truthy, so
|
||||
/// the body runs unless the ENGINE holds it back. AtlasLoot's four QuickLook buttons are the live
|
||||
/// case — unset, they disable themselves in `<OnShow>` and their `<OnEnter>` indexes a nil
|
||||
/// `AtlasLootCharDB.QuickLooks[n]`.
|
||||
/// A disabled Button fires neither `OnEnter` nor `OnLeave`: `CSimpleButton::OnEnter` (`0x779490`)
|
||||
/// and `OnLeave` (`0x7794e0`) test `[esi+0x328]` and skip the base notify (`0x76b6a0`,
|
||||
/// `0x76b6f0`). It still takes the hover: the walk sets `[root+0x7c]` before the notify.
|
||||
#[test]
|
||||
fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
|
||||
let mut s = script();
|
||||
@@ -912,7 +827,6 @@ fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
|
||||
"entering a disabled button fires nothing"
|
||||
);
|
||||
|
||||
// Leaving it is as silent as entering it, and the enabled neighbour is the control.
|
||||
s.mouse_move(250.0, 50.0);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
@@ -920,7 +834,7 @@ fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
|
||||
"no leave from the disabled button; the enabled one notifies normally"
|
||||
);
|
||||
|
||||
// Disabled WHILE hovered: the leave is gated on the state at leave time, not at enter time.
|
||||
// Disabled while hovered: the leave is gated on the state at leave time.
|
||||
s.run("On:Disable()").unwrap();
|
||||
s.mouse_move(400.0, 50.0);
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
//! The frame keyboard delivery law (decision 1319; the walk `0x765f10`, the existence gate
|
||||
//! `0x76b7d0`).
|
||||
//!
|
||||
//! Each test pins one clause that a plausible-but-wrong implementation gets backwards.
|
||||
//! Frame keyboard delivery: the walk (`0x765f10`) and its existence gate (`0x76b7d0`).
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// The bucket gate (`0x76af00`): membership is the **keyboard-enabled flag**, not the presence of a
|
||||
/// script. A Lua-created frame auto-enables nothing (the reference's `SetScript` doesn't either),
|
||||
/// so its handler is unreachable until `EnableKeyboard(true)` puts it in the walk.
|
||||
/// Bucket membership (`0x76af00`) is the keyboard-enabled flag, which `SetScript` never sets.
|
||||
#[test]
|
||||
fn a_key_script_alone_does_not_put_a_frame_in_the_walk() {
|
||||
let mut s = script();
|
||||
@@ -37,9 +32,8 @@ fn a_key_script_alone_does_not_put_a_frame_in_the_walk() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The existence gate's asymmetry (`0x76b7d0`): a frame carrying **only** an `OnKeyUp` consumes
|
||||
/// every key-down and runs nothing. This is the clause an implementation "tidies away" — and doing
|
||||
/// so silently un-suppresses that frame's keybindings.
|
||||
/// The existence gate (`0x76b7d0`) takes either key slot, so a frame with only an `OnKeyUp`
|
||||
/// consumes every key-down and runs nothing.
|
||||
#[test]
|
||||
fn only_an_onkeyup_still_swallows_the_key_down() {
|
||||
let mut s = script();
|
||||
@@ -59,15 +53,12 @@ fn only_an_onkeyup_still_swallows_the_key_down() {
|
||||
"…and fires nothing"
|
||||
);
|
||||
|
||||
// Neither slot: declines outright.
|
||||
s.run("f:SetScript(\"OnKeyUp\", nil)").unwrap();
|
||||
assert!(!s.key_input("ESCAPE"), "no key slot at all: declines");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The walk order (`0x765f10`, `0x764ae2`): **strata descending first**, and only then level. A LOW
|
||||
/// frame can never take a key from a HIGH one, however high its level — the same "a raise never
|
||||
/// changes stratum" shape the toplevel law has.
|
||||
/// The walk (`0x765f10`, `0x764ae2`) runs strata descending, then level, then registration order.
|
||||
#[test]
|
||||
fn the_walk_is_strata_then_level_then_registration() {
|
||||
let mut s = script();
|
||||
@@ -95,7 +86,6 @@ fn the_walk_is_strata_then_level_then_registration() {
|
||||
"strata beats level"
|
||||
);
|
||||
|
||||
// Within one stratum: higher level wins, and equal levels keep registration order.
|
||||
s.run(
|
||||
r#"
|
||||
winner = nil
|
||||
@@ -113,8 +103,7 @@ fn the_walk_is_strata_then_level_then_registration() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **At most one frame consumes** (`0x765f10`): the walk stops at the first consumer, so a second
|
||||
/// keyboard frame below it never sees the key.
|
||||
/// The walk stops at the first consumer (`0x765f10`).
|
||||
#[test]
|
||||
fn the_first_consumer_ends_the_walk() {
|
||||
let mut s = script();
|
||||
@@ -138,8 +127,7 @@ fn the_first_consumer_ends_the_walk() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A hidden frame is in no bucket (the link gate the walk shares with the draw order), so it
|
||||
/// neither fires nor consumes — and the frame below it gets the key instead.
|
||||
/// The walk shares the draw order's link gate, so a hidden frame is in no bucket.
|
||||
#[test]
|
||||
fn a_hidden_frame_is_not_in_the_walk() {
|
||||
let mut s = script();
|
||||
@@ -164,8 +152,7 @@ fn a_hidden_frame_is_not_in_the_walk() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A raising handler must not eat the key or abort the walk's caller: the error is recorded, the
|
||||
/// frame still consumed (consumption is the C++ gate's, never the handler's — `0x7026f0`).
|
||||
/// Consumption is the gate's decision, never the handler's (`0x7026f0`).
|
||||
#[test]
|
||||
fn a_raising_handler_still_consumes_and_is_recorded() {
|
||||
let mut s = script();
|
||||
@@ -185,16 +172,9 @@ fn a_raising_handler_still_consumes_and_is_recorded() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A `CSimpleEditBox` in the walk is asked about FOCUS, never about a script slot** (the vtable
|
||||
/// slot `0x77a900`). Its ctor registers it in both key buckets, and vtable `0x81c910` replaces
|
||||
/// `+0x5c`/`+0x60` with overrides that *never chain to the base* — "there is no `call 0x76b760`
|
||||
/// anywhere in it, so the generic `OnChar` slot `+0x180` is unreachable on an editbox". An
|
||||
/// unfocused box therefore takes the `0x77a956 xor eax,eax` leg: decline, walk continues.
|
||||
///
|
||||
/// Modelling the box as a plain frame here is not a subtle divergence. Stock
|
||||
/// `SendMailNameEditBox` carries an XML `<OnChar>` (`SendMailFrame_SendeeAutocomplete`), which
|
||||
/// auto-enables it, and it registers first in the mail window — so it consumed every keystroke and
|
||||
/// the send tab's other four boxes took no input at all (decision 2145).
|
||||
/// An editbox in the walk is asked about focus, never a script slot (`0x77a900`): vtable
|
||||
/// `0x81c910` overrides `+0x5c`/`+0x60` without calling the base gate `0x76b760`, so an unfocused
|
||||
/// box declines at `0x77a956` and the walk continues.
|
||||
#[test]
|
||||
fn an_unfocused_editbox_declines_rather_than_eating_its_neighbours_keys() {
|
||||
let mut s = script();
|
||||
@@ -223,8 +203,7 @@ fn an_unfocused_editbox_declines_rather_than_eating_its_neighbours_keys() {
|
||||
"the unfocused box's own OnChar is unreachable from the walk — it declines"
|
||||
);
|
||||
|
||||
// Give the decoy the focus and its handler is reachable again — through the INSERT, which is
|
||||
// where an editbox's `OnChar` is fired from (`0x77c200`), not through the walk's base gate.
|
||||
// Focused, the decoy's `OnChar` fires from the insert (`0x77c200`), not from the walk's gate.
|
||||
s.run("KbDecoyBox:SetFocus()").unwrap();
|
||||
assert!(s.char_input("j"), "…and now it is the one that consumes");
|
||||
assert_eq!(s.eval::<i64>("return decoyChars").unwrap(), 1);
|
||||
@@ -240,10 +219,8 @@ fn an_unfocused_editbox_declines_rather_than_eating_its_neighbours_keys() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The same clause on the key-down channel (`+0x60` → `0x77b160`, focus guard at `0x77b1c7`), which
|
||||
/// is the [`super::super::keyboard::frame_key_input`] walk: an unfocused box carrying only an
|
||||
/// `OnKeyUp` must not swallow a BACKSPACE the focused box is waiting for. The plain-frame
|
||||
/// asymmetry above is real *for plain frames*; on a box the base gate is not reached at all.
|
||||
/// The same on the key-down channel (`+0x60` is `0x77b160`, focus guard at `0x77b1c7`): an
|
||||
/// editbox never reaches the base gate, so an `OnKeyUp` alone does not make it consume.
|
||||
#[test]
|
||||
fn an_unfocused_editbox_does_not_swallow_a_focused_boxs_editing_key() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! The layout change gate: a resolve whose inputs are byte-identical to the last converged one is
|
||||
//! skipped outright (`script::layout::InputFingerprint`).
|
||||
//!
|
||||
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
|
||||
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
|
||||
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
|
||||
//! The layout change gate: a resolve whose inputs match the last converged one is skipped
|
||||
//! (`script::layout::InputFingerprint`). The tests count solves (`Model::layout_solves`), since
|
||||
//! equal rects cannot tell a skip from a re-solve.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{Model, UiScript};
|
||||
@@ -24,8 +21,7 @@ fn solves(s: &UiScript) -> u64 {
|
||||
.layout_solves
|
||||
}
|
||||
|
||||
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
|
||||
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
|
||||
/// A screen-anchored frame and a child off it, so a move has to propagate.
|
||||
fn setup(s: &UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -50,7 +46,6 @@ fn an_unchanged_resolve_does_not_run_the_fixpoint() {
|
||||
let after_first = solves(&s);
|
||||
assert_eq!(after_first, 1, "the first resolve must run");
|
||||
|
||||
// Nothing touched in between: every further resolve is a no-op.
|
||||
for _ in 0..5 {
|
||||
s.resolve();
|
||||
}
|
||||
@@ -84,7 +79,6 @@ fn moving_a_frame_reopens_the_gate_and_propagates() {
|
||||
child_left(&s)
|
||||
);
|
||||
|
||||
// And it closes again once the move has settled.
|
||||
let after = solves(&s);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -94,7 +88,6 @@ fn moving_a_frame_reopens_the_gate_and_propagates() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resizing the window moves every top-level frame — the gate must never swallow it.
|
||||
#[test]
|
||||
fn a_screen_resize_reopens_the_gate() {
|
||||
let mut s = script();
|
||||
@@ -113,8 +106,7 @@ fn a_screen_resize_reopens_the_gate() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
|
||||
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
|
||||
/// A region's `SetPoint` writes `region_data`, not `layout_inputs`; the region sweep reads it.
|
||||
#[test]
|
||||
fn moving_a_region_reopens_the_gate() {
|
||||
let mut s = script();
|
||||
@@ -142,10 +134,8 @@ fn moving_a_region_reopens_the_gate() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
|
||||
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
|
||||
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
|
||||
/// show/hide should see that the current model does not need it.
|
||||
/// Hidden frames still resolve (visibility is an extract-time filter), so a hide moves no rect and
|
||||
/// the gate stays closed.
|
||||
#[test]
|
||||
fn hiding_a_frame_does_not_reopen_the_gate() {
|
||||
let mut s = script();
|
||||
@@ -164,22 +154,9 @@ fn hiding_a_frame_does_not_reopen_the_gate() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The hover re-enter loop must be FREE while the content is unchanged.
|
||||
///
|
||||
/// `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` every frame while the tooltip is the
|
||||
/// button's own — faithful, and UNTHROTTLED in 1.12 (its `updateTooltip` throttle is commented
|
||||
/// out; the reference's `ContainerFrameItemButton_OnUpdate` ships the same loop). So a bag hover clears and
|
||||
/// rebuilds the SAME tooltip 60×/sec, and the engine must absorb that: identical content re-derives
|
||||
/// an identical model, so the measure cache re-validates on its content key and the gate stays shut.
|
||||
///
|
||||
/// This is the regression gate for the live report (a bag hover cost +10 CPU ms/frame, ~2 full
|
||||
/// arena solves + a re-shape of every line, every frame): `clear_content` wiped
|
||||
/// `RegionData::measured` — an invalidation the content-hash key already performs — so every
|
||||
/// re-enter re-measured every line, and the non-empty measure list forced a second full resolve
|
||||
/// that the gate could never close over.
|
||||
///
|
||||
/// It drives the app's real per-frame order (`ui_script::extract::tick_script`): Lua tick →
|
||||
/// resolve → measure round-trip → resolve.
|
||||
/// button's, its throttle commented out (`ContainerFrame.lua:645`), so rebuilding identical
|
||||
/// tooltip content must neither re-measure nor re-solve.
|
||||
#[test]
|
||||
fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
let mut s = script();
|
||||
@@ -206,8 +183,7 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
)
|
||||
.expect("setup");
|
||||
|
||||
// The host's font engine: every distinct string has one deterministic size. Counts every
|
||||
// string it is asked to shape, so the test can assert on shaping work directly.
|
||||
// The host's font engine: one fixed size per string.
|
||||
let sizes: &[(&str, f32, f32)] = &[
|
||||
("Small Shield", 80.0, 14.0),
|
||||
("Shield", 50.0, 12.0),
|
||||
@@ -216,8 +192,8 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
("Restores 243 health over 21 sec.", 118.0, 24.0),
|
||||
("Durability 45 / 45", 96.0, 12.0),
|
||||
];
|
||||
// One frame of `tick_script`: tick (the re-enter) → resolve → measure round-trip → resolve.
|
||||
// Returns how many strings the font engine was asked to shape this frame.
|
||||
// One frame in the app's order (`ui_script::extract::tick_script`): tick, resolve, measure
|
||||
// round-trip, resolve. Returns how many strings were shaped.
|
||||
let frame = |s: &mut UiScript| -> usize {
|
||||
s.run("reenter()").expect("re-enter");
|
||||
s.resolve();
|
||||
@@ -241,8 +217,7 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
shaped
|
||||
};
|
||||
|
||||
// Settle: the first frames legitimately shape the five strings and re-solve as the auto-size
|
||||
// pre-pass converges on the fresh measures.
|
||||
// The first frames shape the strings and re-solve while the auto-size pre-pass converges.
|
||||
for _ in 0..4 {
|
||||
frame(&mut s);
|
||||
}
|
||||
@@ -264,12 +239,7 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
solves_before,
|
||||
"a re-enter with identical content must not reopen the layout gate"
|
||||
);
|
||||
// …and it must not even reach tier 2. Tier 1 (the mutation epoch) is what makes a quiet frame
|
||||
// FREE; tier 2 still hashes the whole UI's read set to decide "quiet" (~0.65 ms/frame measured
|
||||
// with `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at solves=0). The re-enter loop used to
|
||||
// pay that on every single hover frame, because `clear_content` wiped each line's wrap pin and
|
||||
// `append_line` re-pinned the same width — a round trip inside one frame, two epoch bumps.
|
||||
// Idempotent content must be epoch-silent, not merely fingerprint-absorbed.
|
||||
// Nor may it reach tier 2, which hashes the whole UI: identical content leaves the epoch alone.
|
||||
let epoch_before = epoch(&s);
|
||||
for _ in 0..10 {
|
||||
frame(&mut s);
|
||||
@@ -281,9 +251,8 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
hold, or every hover frame pays the whole-UI fingerprint"
|
||||
);
|
||||
|
||||
// The gate is shut because nothing MOVED — not because the tooltip went stale. Changed content
|
||||
// must still re-measure exactly its own line, and a measure that resizes the auto-sized plate
|
||||
// (this one overtakes the double line as the widest) must reopen the gate.
|
||||
// Changed content re-measures only its own line, and a measure that widens the auto-sized
|
||||
// plate reopens the gate.
|
||||
s.run(
|
||||
r#"function reenter()
|
||||
TT:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
@@ -328,28 +297,16 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Tier 1 of the gate (decision 0740): the mutation epoch. **Any CONVERGED resolve closes it**
|
||||
/// (decision 1385) — the fingerprint is hashed over inputs alone, so a solve cannot outgrow the
|
||||
/// value it stores — and from then on a quiet frame skips at a `u64` compare without computing
|
||||
/// the fingerprint at all.
|
||||
/// Whether tier 1, the mutation epoch, is closed: any converged resolve closes it, and a quiet
|
||||
/// frame then skips at a `u64` compare without hashing the fingerprint.
|
||||
fn tier_one_closed(s: &UiScript) -> bool {
|
||||
let m = s.lua().app_data_ref::<Model>().expect("model app_data");
|
||||
m.layout_epoch_resolved == Some(m.layout_epoch)
|
||||
}
|
||||
|
||||
/// **The castbar law** (decision 1385, ledger B283): a region that MOVES every frame — the classic
|
||||
/// OnUpdate animation idiom, our own `CastingBarSpark:SetPoint`, and every addon that slides a
|
||||
/// texture — must cost exactly **one** let-through resolve per frame.
|
||||
///
|
||||
/// It used to cost three. The fingerprint was hashed over the 0294 SEEDS as well as the inputs, so
|
||||
/// a solve necessarily outgrew the fingerprint it had just stored: neither that pass nor the
|
||||
/// settling pass behind it could close tier 1, and the frame paid solve + settle + skip — three
|
||||
/// whole-roster walks at ~1.0–1.4 ms each. Measured live at the Stormwind pin: **+4.2 ms of CPU
|
||||
/// per frame for one 32×32 spark**, on a default UI with no addons loaded.
|
||||
///
|
||||
/// This is the regression guard for that whole bug class. `solves` is the honest counter — a
|
||||
/// wasted walk that concludes "nothing moved" still costs the full preamble — so the assertion is
|
||||
/// on the count, never on a duration (0735: milliseconds are not evidence of a scope regression).
|
||||
/// A region moved every frame, as `CastingBarSpark:SetPoint` does (`CastingBarFrame.lua:114`),
|
||||
/// costs exactly one solve per frame: the fingerprint hashes inputs alone, so that solve closes
|
||||
/// tier 1.
|
||||
#[test]
|
||||
fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
|
||||
let mut s = script();
|
||||
@@ -366,13 +323,8 @@ fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
|
||||
s.resolve();
|
||||
assert!(tier_one_closed(&s), "the setup must settle in one resolve");
|
||||
|
||||
// Ten frames of the castbar's own inner loop: one region re-pointed to a NEW offset, then the
|
||||
// host's per-frame resolve. Nothing else in the model moves.
|
||||
//
|
||||
// The offsets start at 1.7, not 0 — a `frame * 1.7` sequence opens with a re-write of the
|
||||
// seed value, which the setters' bit-exact compare absorbs (no epoch bump, no solve at all).
|
||||
// That is the *right* answer and `an_idempotent_setter_call_leaves_tier_one_closed` pins it;
|
||||
// here it would have measured the guard instead of the gate.
|
||||
// Ten castbar frames: the spark re-pointed to a new offset, then the per-frame resolve. The
|
||||
// offsets start at 1.7, not 0, since the setters' compare absorbs a write of the seed value.
|
||||
for frame in 0..10 {
|
||||
let before = solves(&s);
|
||||
s.run(&format!(
|
||||
@@ -396,8 +348,7 @@ fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The other half of the same law: once it has settled, an untouched frame costs NOTHING — no
|
||||
/// solve, and (tier 1) not even the fingerprint walk that would decide so.
|
||||
/// A settled, untouched frame costs no solve, and tier 1 spares it the fingerprint walk too.
|
||||
#[test]
|
||||
fn a_quiet_frame_after_a_move_costs_no_solve_at_all() {
|
||||
let mut s = script();
|
||||
@@ -421,9 +372,7 @@ fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
|
||||
// ONE resolve closes the epoch (decision 1385): the fingerprint is hashed over inputs alone,
|
||||
// and the rounds just drove those inputs to their fixpoint, so there is nothing left for a
|
||||
// settling pass to discover. This used to need two.
|
||||
// One resolve closes the epoch: the fingerprint hashes inputs alone, now at their fixpoint.
|
||||
s.resolve();
|
||||
assert!(
|
||||
tier_one_closed(&s),
|
||||
@@ -448,9 +397,7 @@ fn an_idempotent_setter_call_leaves_tier_one_closed() {
|
||||
s.resolve();
|
||||
assert!(tier_one_closed(&s));
|
||||
|
||||
// The classic per-frame OnUpdate idiom: re-assert the exact same geometry every frame. The
|
||||
// setters' compare-before-write keeps the epoch untouched, so an idle UI never re-enters
|
||||
// the fingerprint path at all.
|
||||
// The OnUpdate idiom of re-setting the same geometry: the setters compare before writing.
|
||||
s.run(
|
||||
r#"
|
||||
parent:SetWidth(100); parent:SetHeight(40)
|
||||
@@ -473,8 +420,8 @@ fn a_paint_only_write_leaves_tier_one_closed() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
// A paint region with NO anchors: its region_data entry is created by a paint setter and
|
||||
// must stay invisible to the layout gate (the resolve sweep skips anchor-less entries).
|
||||
// A region with no anchors: a paint setter creates its `region_data` entry, which the resolve
|
||||
// sweep skips, so the gate must ignore it.
|
||||
s.run(r#"tex = parent:CreateTexture(nil, "ARTWORK")"#)
|
||||
.expect("region");
|
||||
s.resolve();
|
||||
@@ -491,17 +438,8 @@ fn a_paint_only_write_leaves_tier_one_closed() {
|
||||
assert_eq!(solves(&s), before);
|
||||
}
|
||||
|
||||
/// **The retarget falsifier** (decision 1625): after a node is re-pointed from target A to target
|
||||
/// B, the cached graph's EDGES must describe the new shape — moving B must move the node.
|
||||
///
|
||||
/// This is the one direction that can be silently wrong. 1625 keeps the cached graph across an
|
||||
/// anchor retarget by patching one node's edges instead of re-deriving all of them, and a patch
|
||||
/// that dropped the *new* edge would under-dirty: the node would sit at a stale rect while the
|
||||
/// thing it is anchored to moved. (The other direction — a leftover edge to the OLD target — only
|
||||
/// costs a needless re-solve, so it is asserted second and separately.)
|
||||
///
|
||||
/// It asserts on RECTS, not on counts, because a wrong edge set is a wrong picture: the failure
|
||||
/// this guards against is a tooltip left behind at the last button's position.
|
||||
/// A retarget patches one node's edges in the cached graph instead of re-deriving it, so the new
|
||||
/// edge must land: moving the new target moves the node, and moving the old one does not.
|
||||
#[test]
|
||||
fn a_retargeted_anchor_follows_its_new_target() {
|
||||
let mut s = script();
|
||||
@@ -521,8 +459,7 @@ fn a_retargeted_anchor_follows_its_new_target() {
|
||||
s.resolve();
|
||||
s.resolve();
|
||||
|
||||
// The retarget: the plate now hangs off B, exactly as `GameTooltip:SetOwner` re-points the
|
||||
// plate at the next button under the cursor.
|
||||
// The plate now hangs off B, as `GameTooltip:SetOwner` re-points it at the next button.
|
||||
s.run(r#"plate:SetPoint("TOPLEFT", b, "TOPRIGHT", 0, 0)"#)
|
||||
.expect("retarget");
|
||||
s.resolve();
|
||||
@@ -532,9 +469,6 @@ fn a_retargeted_anchor_follows_its_new_target() {
|
||||
"the retargeted plate must sit at B's right edge (110), not {after_retarget}"
|
||||
);
|
||||
|
||||
// …and it must keep following B. This is the assertion the edge patch exists to satisfy: the
|
||||
// graph was NOT re-derived across the retarget, so if the new edge never made it into the
|
||||
// cached edge set, B moving reaches nothing and the plate stays put.
|
||||
s.run(r#"b:SetPoint("TOPLEFT", nil, "TOPLEFT", 300, 0)"#)
|
||||
.expect("move B");
|
||||
s.resolve();
|
||||
@@ -545,7 +479,6 @@ fn a_retargeted_anchor_follows_its_new_target() {
|
||||
retarget's edge patch dropped the new edge and the node is under-dirtied (decision 1625)"
|
||||
);
|
||||
|
||||
// …and it must NOT follow the old one any more.
|
||||
s.run(r#"a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, -50)"#)
|
||||
.expect("move A");
|
||||
s.resolve();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! The measure round-trip + frame→region anchors, end to end.
|
||||
//! Text measurement: the host round-trip, the same-tick font engine, and the layout they feed.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// The measure round-trip + frame→region anchors, end to end: a height-less FontString reports a
|
||||
/// [`MeasureRequest`]; the host answer becomes its implicit size; and a FRAME anchored to that
|
||||
/// FontString by name binds to its measured bottom in resolve's second round — the real gossip
|
||||
/// structure (option rows hang off the greeting's laid-out height, ref-GossipFrame.xml l.258-261).
|
||||
/// A height-less FontString's [`MeasureRequest`] answer becomes its size, and a frame anchored to
|
||||
/// it binds to the measured bottom in resolve's second round, as gossip option rows hang off the
|
||||
/// greeting text (`GossipFrame.xml:258`).
|
||||
#[test]
|
||||
fn measured_fontstring_height_feeds_frame_anchors() {
|
||||
let mut s = script();
|
||||
@@ -48,7 +47,7 @@ fn measured_fontstring_height_feeds_frame_anchors() {
|
||||
})
|
||||
.expect("greeting rect");
|
||||
assert_eq!((g.top, g.bottom, g.left), (409.0, 361.0, 33.0));
|
||||
// Row1: TOPLEFT → Greeting BOTTOMLEFT +(-10,-20) ⇒ top 341, left 23 — bound in round 2.
|
||||
// Row1: TOPLEFT → Greeting BOTTOMLEFT +(-10,-20) ⇒ top 341, left 23, bound in round 2.
|
||||
let row = quads
|
||||
.iter()
|
||||
.find_map(|q| match q.target {
|
||||
@@ -60,13 +59,9 @@ fn measured_fontstring_height_feeds_frame_anchors() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A stored measure serves ONLY the current text: after `SetText` to a DIFFERENT string, the old
|
||||
/// string's width must not leak through `GetWidth`/`GetStringWidth` — the whisper-header cursor
|
||||
/// bug: the chat edit box ran `SetTextInsets(15 + header:GetWidth(), …)` on a type switch
|
||||
/// (Say → "Tell Alice:") and its `w > 1` settle gate passed with the PREVIOUS header's measure,
|
||||
/// latching the caret inside the new header. The metric read is key-checked
|
||||
/// ([`crate::script::RegionData`]'s measure key): a changed string reads 0 until its own measure
|
||||
/// lands, so poll-until-nonzero callers converge on the RIGHT width.
|
||||
/// A stored measure serves only its own text: reads check [`crate::script::RegionData`]'s measure
|
||||
/// key, so a changed string reads 0 until its measure lands, never the old width. The chat header
|
||||
/// reads its width right after a `SetText` (`ChatFrame.lua:1912`).
|
||||
#[test]
|
||||
fn a_changed_text_reads_zero_until_its_own_measure_lands() {
|
||||
let mut s = script();
|
||||
@@ -92,7 +87,7 @@ fn a_changed_text_reads_zero_until_its_own_measure_lands() {
|
||||
.unwrap(),
|
||||
30.0
|
||||
);
|
||||
// The type switch: same region, new text. The old measure must NOT serve.
|
||||
// The type switch: same region, new text.
|
||||
s.run(r#"getglobal("Header"):SetText("Tell Alice: ")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -101,7 +96,6 @@ fn a_changed_text_reads_zero_until_its_own_measure_lands() {
|
||||
0.0,
|
||||
"a stale measure must not serve for changed text"
|
||||
);
|
||||
// The round-trip re-measures the new string; the true width serves.
|
||||
s.resolve();
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
assert_eq!(reqs.len(), 1, "the changed text wants re-measuring");
|
||||
@@ -117,11 +111,9 @@ fn a_changed_text_reads_zero_until_its_own_measure_lands() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A zero-WIDTH FontString with an explicit height auto-sizes its width to the measured line —
|
||||
/// the reference label idiom (`<Size x="0" y="16"/>` anchored TOPRIGHT→TOPLEFT: MailFrame's
|
||||
/// "From:"/"Subject:" labels end at the anchor and grow leftward, and the value string anchored
|
||||
/// LEFT→label RIGHT starts past them, never overlapping). Gating the measure on height alone
|
||||
/// left these rects zero-width — "From" and the sender name painted on top of each other.
|
||||
/// A zero-width FontString with a height auto-sizes its width to the measured line: MailFrame's
|
||||
/// `OpenMailSenderLabel` (`MailFrame.xml:1391`) grows leftward from its anchor, and the sender
|
||||
/// name anchored to its right edge starts past it.
|
||||
#[test]
|
||||
fn zero_width_fontstring_autosizes_to_its_line() {
|
||||
let mut s = script();
|
||||
@@ -163,17 +155,14 @@ fn zero_width_fontstring_autosizes_to_its_line() {
|
||||
.eval("return FromLabel:GetLeft(), FromLabel:GetRight(), FromLabel:GetStringWidth()")
|
||||
.unwrap();
|
||||
assert_eq!((l_left, l_right, l_w), (74.0, 114.0, 40.0));
|
||||
// Value: starts 5 past the label's real right edge — no overlap.
|
||||
// Value: 5 past the label's measured right edge.
|
||||
let v_left: f32 = s.eval("return FromValue:GetLeft()").unwrap();
|
||||
assert_eq!(v_left, 119.0);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The frame-scale seam (0219 §2's divergence, closed): a `SetScale`'d frame's quads carry its
|
||||
/// `effective_scale` out to the renderer — the rect is already scale-multiplied by layout, and
|
||||
/// the renderer needs the same factor for the GLYPH raster size — and the measure round-trip
|
||||
/// rides it too: the request names the scale (the host measures at the drawn size), the cache
|
||||
/// key holds it (a re-scale re-measures), all before any quad draws stale text.
|
||||
/// A scaled frame's quads carry its effective scale for the glyph raster (layout already scaled
|
||||
/// the rect), and the measure request and its cache key carry it too.
|
||||
#[test]
|
||||
fn frame_scale_rides_the_quads_and_the_measure_key() {
|
||||
let mut s = script();
|
||||
@@ -190,7 +179,6 @@ fn frame_scale_rides_the_quads_and_the_measure_key() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// The measure request carries the owner's effective scale.
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
assert_eq!(reqs.len(), 1);
|
||||
let r = &reqs[0];
|
||||
@@ -200,18 +188,15 @@ fn frame_scale_rides_the_quads_and_the_measure_key() {
|
||||
s.set_measured_text_unwrapped(&[(id, 50.0, 16.0, key)]);
|
||||
s.resolve();
|
||||
assert!(s.fontstrings_needing_measure().is_empty(), "cache warm");
|
||||
// Every quad of the scaled frame carries the scale — frame slot and region alike.
|
||||
let quads = s.extract();
|
||||
let label = quads
|
||||
.iter()
|
||||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Options"))
|
||||
.expect("label quad");
|
||||
assert_eq!(label.scale, 0.8);
|
||||
// The label's rect is scale-multiplied by layout (width 50 × 0.8 = 40) — the quad's scale is
|
||||
// for the glyph raster, not a second rect multiply.
|
||||
// Layout already scaled the rect (50 × 0.8 = 40); the quad's scale is for the glyphs only.
|
||||
let lr = label.rect.expect("label rect");
|
||||
assert!((lr.width() - 40.0).abs() < 0.01, "width {}", lr.width());
|
||||
// A re-scale invalidates the measure key: the same text re-measures at the new drawn size.
|
||||
s.run(r#"Win:SetScale(1.25)"#).unwrap();
|
||||
s.resolve();
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
@@ -221,12 +206,9 @@ fn frame_scale_rides_the_quads_and_the_measure_key() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The host's raster-environment invalidation ([`UiScript::invalidate_text_measures`]): a warm
|
||||
/// FontString measure cache re-requests after the call — same content, same key (the key hashes
|
||||
/// content, not the host's seam scale; the STALENESS is the host's to declare, which is the whole
|
||||
/// seam). This is the engine half of the fullscreen-truncation fix: measures answered under one
|
||||
/// seam scale kept satisfying fit tests run under another, and the ellipsis ate fitting text
|
||||
/// ("Contr...", director 2026-08-04; reproduced end-to-end by `WOW_RESIZE`).
|
||||
/// [`UiScript::invalidate_text_measures`] re-requests every warm measure under the same key: the
|
||||
/// key hashes content, not the host's raster scale, so only the host knows when a measure is
|
||||
/// stale, and a stale one truncates text that fits.
|
||||
#[test]
|
||||
fn invalidate_text_measures_reopens_the_round_trip() {
|
||||
let mut s = script();
|
||||
@@ -254,14 +236,11 @@ fn invalidate_text_measures_reopens_the_round_trip() {
|
||||
);
|
||||
|
||||
s.invalidate_text_measures();
|
||||
// The measured extent is gone (GetStringWidth back to its unmeasured 0)…
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Label:GetStringWidth()").unwrap(),
|
||||
0.0,
|
||||
"stale measure dropped, not served"
|
||||
);
|
||||
// …and the round-trip reopens with the SAME content key — the request is the host's cue to
|
||||
// re-measure under its new environment; nothing about the region itself changed.
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
assert_eq!(reqs.len(), 1, "re-requests after invalidation");
|
||||
assert_eq!(
|
||||
@@ -271,20 +250,9 @@ fn invalidate_text_measures_reopens_the_round_trip() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`GetStringWidth` is the NATURAL width — never the box, never the wrapped extent**
|
||||
/// (`0x79e510`→`0x772890`: the reference re-measures the raw text with no wrap constraint, so "Lua
|
||||
/// sees the natural, unwrapped, un-truncated width at the DRAWN size").
|
||||
///
|
||||
/// This is the distinction whose absence made the reference's own `PanelTemplates_TabResize` a
|
||||
/// feedback loop in this engine (decision 0997): the kit sized a tab from `GetStringWidth`, set that
|
||||
/// width on the label, and read its own output back next frame — a tab that changed width every
|
||||
/// single frame. Three separate things are pinned here because each one was wrong on its own:
|
||||
///
|
||||
/// 1. a region with a DECLARED width still gets a measure request (it used to be skipped: no
|
||||
/// auto-sized axis, no request — so a constrained label could never learn its natural width);
|
||||
/// 2. `GetStringWidth` answers with the natural width, while `GetWidth` keeps echoing the laid-out
|
||||
/// extent that auto-size depends on;
|
||||
/// 3. the answer does not move when the declared width does.
|
||||
/// `GetStringWidth` is the natural width, unwrapped and untruncated at the drawn size: the
|
||||
/// reference re-measures the raw text with no wrap (`0x79e510`, `0x772890`). `GetWidth` stays the
|
||||
/// laid-out box.
|
||||
#[test]
|
||||
fn get_string_width_is_the_natural_width_not_the_box() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -303,8 +271,7 @@ fn get_string_width_is_the_natural_width_not_the_box() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// 1 · Both axes are declared, so nothing about the LAYOUT needs a measure — and the request is
|
||||
// issued anyway, because `GetStringWidth` has no other way to learn the natural width.
|
||||
// 1 · A fully declared box still requests a measure, for the natural width.
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
assert_eq!(reqs.len(), 1, "a fully-sized FontString still measures");
|
||||
let r = &reqs[0];
|
||||
@@ -326,8 +293,7 @@ fn get_string_width_is_the_natural_width_not_the_box() {
|
||||
"GetWidth is the laid-out box the auto-size path depends on"
|
||||
);
|
||||
|
||||
// 3 · Narrowing the box re-opens the round trip (the key carries the wrap) and, once answered,
|
||||
// leaves the natural width exactly where it was. This is the loop that used to close.
|
||||
// 3 · A narrower box re-measures (the key carries the wrap); the natural width stays put.
|
||||
s.run("Label:SetWidth(30)").unwrap();
|
||||
s.resolve();
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
@@ -341,14 +307,10 @@ fn get_string_width_is_the_natural_width_not_the_box() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The SYNCHRONOUS measure — a host font engine installed into the VM ([`TextMeasure`])
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The synchronous measure: a host font engine installed in the VM (`TextMeasure`) ──
|
||||
|
||||
/// A stand-in font engine: every character is `PER_CHAR` wide and every line `LINE_H` tall, wrapped
|
||||
/// greedily at the request's wrap width. Deliberately not the real one — what these tests are about
|
||||
/// is *when* the answer arrives, and a fake with arithmetic anyone can do in their head is what
|
||||
/// makes the assertions readable. The real engine's own numbers are the app's to test.
|
||||
/// A stand-in font engine: each character is `PER_CHAR` wide and each line `LINE_H` tall, wrapped
|
||||
/// greedily at the request's wrap width.
|
||||
struct BlockFont;
|
||||
|
||||
const PER_CHAR: f32 = 7.0;
|
||||
@@ -367,13 +329,8 @@ impl TextMeasure for BlockFont {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The director's bug, at the engine.** `SetText` then `GetStringWidth` in ONE tick must answer
|
||||
/// with the string's width — the shape `Bagnon_Forever/database/ui.lua:58-59` writes over every
|
||||
/// saved character (`button:SetText(player)` then `button:GetTextWidth() + 40`), and the shape the
|
||||
/// reference's own `SmallMoneyFrame` writes (MoneyFrame.lua l.202).
|
||||
///
|
||||
/// Without an engine installed this is 0 until the host's round-trip lands at extract — a frame
|
||||
/// later. That is what sized Bagnon's character dropdown to 40px with seven names hanging out of it.
|
||||
/// With a font engine installed, `SetText` then a width read in the same tick gets the string's
|
||||
/// width, as the money frame's `SetText` then `GetTextWidth` expects (`MoneyFrame.lua:202`).
|
||||
#[test]
|
||||
fn a_width_read_in_the_tick_that_set_the_text_is_not_zero() {
|
||||
let mut s = script();
|
||||
@@ -398,9 +355,8 @@ fn a_width_read_in_the_tick_that_set_the_text_is_not_zero() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same read WITHOUT an engine stays 0 and stays served by the round-trip — the pre-existing
|
||||
/// behaviour, still exactly itself. Every engine test and every headless run is this VM, so the
|
||||
/// synchronous path may never be a correctness precondition for anything.
|
||||
/// Without an engine the read is 0 until the round-trip answers; headless runs and most tests have
|
||||
/// no engine, so nothing may depend on the synchronous path.
|
||||
#[test]
|
||||
fn with_no_engine_installed_the_round_trip_is_still_the_only_answer() {
|
||||
let mut s = script();
|
||||
@@ -424,9 +380,8 @@ fn with_no_engine_installed_the_round_trip_is_still_the_only_answer() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A FontString's **box** is right in the frame its text was set, not the frame after: `resolve`
|
||||
/// closes the round-trip itself when an engine is installed, so a caller reading `GetWidth()` (the
|
||||
/// laid-out extent, not the natural one) gets this string's number and not the previous string's.
|
||||
/// With an engine installed, `resolve` answers its own measure requests, so `GetWidth()` is right
|
||||
/// in the frame the text was set.
|
||||
#[test]
|
||||
fn resolve_closes_the_round_trip_when_an_engine_is_installed() {
|
||||
let mut s = script();
|
||||
@@ -458,9 +413,8 @@ fn resolve_closes_the_round_trip_when_an_engine_is_installed() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same-tick measure and the batch round-trip write the **same cache**, so asking twice costs
|
||||
/// one measure — and a caller polling `GetStringWidth` every frame does not re-measure a string
|
||||
/// that has not changed.
|
||||
/// The same-tick measure and the batch round-trip share one cache, so an unchanged string is
|
||||
/// measured once however often it is read.
|
||||
#[test]
|
||||
fn a_synchronous_measure_satisfies_the_batch_request_too() {
|
||||
let mut s = script();
|
||||
@@ -482,7 +436,6 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() {
|
||||
s.fontstrings_needing_measure().is_empty(),
|
||||
"the Lua read already filled the cache the batch pass keys on"
|
||||
);
|
||||
// …and a new string invalidates it exactly as it always did.
|
||||
s.run("Label:SetText('a longer label')").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return Label:GetStringWidth()").unwrap(),
|
||||
@@ -491,13 +444,8 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The measure LEDGER's completeness guard: `fontstrings_needing_measure` no longer walks the
|
||||
/// roster on the armed path — a measure-input write the ledger never hears about is a label
|
||||
/// whose box silently never updates again. The exposing sequence is SETTLE (drain the ledger
|
||||
/// dry), then one silent write, then sweep: enrolled, the sweep names exactly that region;
|
||||
/// missed, it returns empty and this test is the red light. (Watched red with the `SetText`
|
||||
/// enrollment removed — the earlier tests in this file stay green through that removal, because
|
||||
/// their regions ride the ledger's own re-enrollment of still-unanswered requests.)
|
||||
/// `fontstrings_needing_measure` sweeps only the regions a write enrolled, so every measure-input
|
||||
/// write must enroll its region: settle, write once, and the sweep must name that region.
|
||||
#[test]
|
||||
fn a_settled_region_is_refound_after_each_measure_input_write() {
|
||||
let mut s = script();
|
||||
@@ -514,17 +462,13 @@ fn a_settled_region_is_refound_after_each_measure_input_write() {
|
||||
.unwrap();
|
||||
let writes: &[(&str, &str)] = &[
|
||||
("SetText", "Label:SetText('two')"),
|
||||
// `SetFormattedText` was a second row here; it is gone (2142), and its era spelling
|
||||
// `SetText(format(...))` is the row above's path exactly.
|
||||
(
|
||||
"SetFont",
|
||||
r#"Label:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")"#,
|
||||
),
|
||||
("SetTextHeight", "Label:SetTextHeight(20)"),
|
||||
("SetWidth (the wrap width)", "Label:SetWidth(123)"),
|
||||
// The conservative lane: a scale write cannot name descendants one by one, so it must
|
||||
// put the ledger back on the whole-roster walk — which refinds Label (scale is in the
|
||||
// measure key).
|
||||
// A scale write cannot name its descendants, so it falls back to the whole-roster walk.
|
||||
("SetScale (conservative)", "Host:SetScale(2.0)"),
|
||||
];
|
||||
for (label, lua) in writes {
|
||||
@@ -546,8 +490,7 @@ fn a_settled_region_is_refound_after_each_measure_input_write() {
|
||||
"{label}: a silent measure-input write must re-surface its region to the sweep"
|
||||
);
|
||||
}
|
||||
// The button label lane writes through its own binding (`Button:SetText` routes to the
|
||||
// ButtonText region) — same guard, second write path.
|
||||
// `Button:SetText` writes its ButtonText region through its own binding.
|
||||
s.run(
|
||||
r#"
|
||||
local b = CreateFrame("Button", "Btn")
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! The Minimap widget kind (decision 0203): zoom API + the extracted content hole.
|
||||
//! The Minimap widget kind: the zoom API and the extracted content hole.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
use crate::widget::{MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS};
|
||||
|
||||
/// A `<Minimap>`-kind frame carries its zoom out through extraction as [`QuadContent::Minimap`]
|
||||
/// at the frame's own draw slot, and the zoom API clamps like the client's `set_zoom` (0..=5).
|
||||
/// The zoom rides extraction as [`QuadContent::Minimap`]; `SetZoom` clamps to 0..=5.
|
||||
#[test]
|
||||
fn minimap_zoom_api_and_extract() {
|
||||
let mut s = script();
|
||||
@@ -18,7 +17,7 @@ fn minimap_zoom_api_and_extract() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Defaults + the clamp law. Both indices seed from the CVar default "3", not 0.
|
||||
// Both indices seed from the CVar default "3", not 0.
|
||||
assert_eq!(
|
||||
s.eval::<u8>("return m:GetZoom()").unwrap(),
|
||||
MINIMAP_DEFAULT_ZOOM
|
||||
@@ -38,7 +37,6 @@ fn minimap_zoom_api_and_extract() {
|
||||
s.run("m:SetZoom(-2)").unwrap();
|
||||
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 0);
|
||||
|
||||
// The widget's own slot extracts as the Minimap content hole, carrying the zoom.
|
||||
s.resolve();
|
||||
let mm = s
|
||||
.extract()
|
||||
@@ -62,7 +60,7 @@ fn minimap_zoom_api_and_extract() {
|
||||
"a sized+anchored Minimap resolves a rect"
|
||||
);
|
||||
|
||||
// Duck-typing: the zoom API must NOT leak onto other kinds (per-kind method registries).
|
||||
// The zoom methods exist only on the Minimap kind; addons duck-type on them.
|
||||
s.run(r#"plain = CreateFrame("Frame", "PlainF")"#).unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return plain.SetZoom == nil").unwrap(),
|
||||
@@ -70,9 +68,8 @@ fn minimap_zoom_api_and_extract() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The client keeps **two** zoom indices and routes `GetZoom`/`SetZoom` on WMO containment (the
|
||||
/// inside flag `0xceaa60` → outdoor `0x86f698` / indoor `0x86f69c`). Each persists across the
|
||||
/// transition: zooming the inn's map right in must not disturb the zoom you left outside.
|
||||
/// Two zoom indices, outdoor `0x86f698` and indoor `0x86f69c`, picked by the WMO inside flag
|
||||
/// `0xceaa60`; each persists across the transition.
|
||||
#[test]
|
||||
fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
let mut s = script();
|
||||
@@ -85,11 +82,9 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Outside: the zoom API drives the outdoor index.
|
||||
s.run("m:SetZoom(2)").unwrap();
|
||||
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 2);
|
||||
|
||||
// Step inside: the API now reads/writes the indoor index, still at its own untouched default.
|
||||
s.set_minimap_inside(true);
|
||||
assert_eq!(
|
||||
s.eval::<u8>("return m:GetZoom()").unwrap(),
|
||||
@@ -118,7 +113,6 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
mm.content
|
||||
);
|
||||
|
||||
// Step back outside: the outdoor zoom is exactly where we left it.
|
||||
s.set_minimap_inside(false);
|
||||
assert_eq!(
|
||||
s.eval::<u8>("return m:GetZoom()").unwrap(),
|
||||
@@ -127,10 +121,8 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The level persists** (decision 1131). `SetZoom` writes the live index *and* the matching CVar
|
||||
/// — `minimapInsideZoom` while inside a WMO, `minimapZoom` outside — the client's own `set_zoom` →
|
||||
/// `CVar::Set` pair, which is the whole reason a zoom survives a restart. The host push in the
|
||||
/// other direction (the seed) does *not* echo back as a change.
|
||||
/// `SetZoom` writes the live index and its CVar (`minimapInsideZoom` inside a WMO, `minimapZoom`
|
||||
/// outside), as the client's `set_zoom` calls `CVar::Set`; the host's seed does not echo back.
|
||||
#[test]
|
||||
fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
let mut s = script();
|
||||
@@ -138,13 +130,11 @@ fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
|
||||
.unwrap();
|
||||
|
||||
// The seed is a HOST write: it moves both live indices and queues nothing (the host is the
|
||||
// one that just read them off disk — an echo would re-dirty the file it loaded).
|
||||
// The seed is a host write: it moves both indices and queues nothing.
|
||||
s.set_minimap_zoom(1, 4);
|
||||
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 1);
|
||||
assert!(s.take_cvar_changes().is_empty(), "the seed must not echo");
|
||||
|
||||
// Outdoors, a zoom writes the outdoor CVar and only that one.
|
||||
s.run("m:SetZoom(5)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
@@ -152,7 +142,6 @@ fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
);
|
||||
assert_eq!(s.cvar("minimapInsideZoom").as_deref(), Some("3"));
|
||||
|
||||
// Indoors it writes the indoor one — the two levels persist separately, like the indices.
|
||||
s.set_minimap_inside(true);
|
||||
s.run("m:SetZoom(0)").unwrap();
|
||||
assert_eq!(
|
||||
@@ -161,11 +150,11 @@ fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
);
|
||||
assert_eq!(s.cvar("minimapZoom").as_deref(), Some("5"));
|
||||
|
||||
// A no-op zoom queues nothing: quiet frames stay quiet, and the config stays clean.
|
||||
// A no-op zoom queues nothing.
|
||||
s.run("m:SetZoom(0)").unwrap();
|
||||
assert!(s.take_cvar_changes().is_empty());
|
||||
|
||||
// The seed clamps like `set_zoom` does, so a hand-edited config.toml cannot seed out of range.
|
||||
// The seed clamps like `set_zoom`.
|
||||
s.set_minimap_zoom(99, 99);
|
||||
s.set_minimap_inside(false);
|
||||
assert_eq!(
|
||||
@@ -174,9 +163,7 @@ fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A VM whose host registered nothing (a bare test harness, a glue-only run) still zooms — the
|
||||
/// engine-side CVar write is a silent no-op there, not a warning: engine writes are code, not UI
|
||||
/// content, so a miss means this build's host does not back the var.
|
||||
/// With no host CVars (a bare harness, a glue-only run) the engine-side write is a silent no-op.
|
||||
#[test]
|
||||
fn zooming_without_a_registered_cvar_table_is_silent() {
|
||||
let mut s = script();
|
||||
@@ -190,17 +177,9 @@ fn zooming_without_a_registered_cvar_table_is_silent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The nine engine-created `Model` children, and why `[9]` is the player arrow.**
|
||||
///
|
||||
/// The `CMinimap` ctor `0x4edbc0` builds nine `CSimpleModel` children parented to the Minimap
|
||||
/// before anything else touches the widget, in three source-ordered groups (`__LINE__` 1424 / 1438
|
||||
/// / 1450), the last being
|
||||
/// `[Minimap+0x338]`, the player arrow. Because both linkers append at the tail and the ctor runs
|
||||
/// before the XML `<Frames>` descent, `({Minimap:GetChildren()})[9]` is that arrow on a stock
|
||||
/// client — which is exactly what Questie's `QuestieArrow.lua` and pfQuest's `compat/client.lua`
|
||||
/// index, unguarded, to read the player's heading.
|
||||
///
|
||||
/// This test is written the way those addons read it, not the way we store it.
|
||||
/// The `CMinimap` ctor (`0x4edbc0`) builds nine `CSimpleModel` children before the XML `<Frames>`
|
||||
/// descent, the last being the player arrow `[Minimap+0x338]`, so `({Minimap:GetChildren()})[9]`
|
||||
/// is the arrow; Questie and pfQuest read the player's heading from it.
|
||||
#[test]
|
||||
fn a_minimap_is_born_with_nine_model_children_and_the_ninth_is_the_player_arrow() {
|
||||
let mut s = script();
|
||||
@@ -225,20 +204,17 @@ fn a_minimap_is_born_with_nine_model_children_and_the_ninth_is_the_player_arrow(
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// Questie's `GetPlayerFacing()`, verbatim. It is `0` until the app pushes, which is the
|
||||
// client's own ctor default (`[frame+0x39c] = 0` at `0x76c92d`) — not nil, and not an error.
|
||||
// Questie's `GetPlayerFacing()`; 0 until the app pushes, the ctor default (`0x76c92d`).
|
||||
s.run("function GetPlayerFacing() return ({Minimap:GetChildren()})[9]:GetFacing() end")
|
||||
.unwrap();
|
||||
s.run(r#"Minimap = m"#).unwrap();
|
||||
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), 0.0);
|
||||
|
||||
// `SetPlayerFacing 0x4eb8e0` writes the argument into `[[minimap+0x338]+0x39c]` VERBATIM — no
|
||||
// negation, no offset, no unit change (a `mov [ecx+0x39c],edx` of the dword it was handed).
|
||||
// `SetPlayerFacing 0x4eb8e0` stores the argument into `[[minimap+0x338]+0x39c]` unchanged.
|
||||
s.set_minimap_player_facing(2.5);
|
||||
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), 2.5);
|
||||
|
||||
// It reaches the arrow through the Minimap's own slot, so an addon appending its own child
|
||||
// cannot displace it: a later `CreateFrame(_, _, Minimap)` lands at index 10.
|
||||
// An addon's own child lands at index 10 and does not displace the arrow.
|
||||
s.run(r#"extra = CreateFrame("Frame", nil, m)"#).unwrap();
|
||||
s.set_minimap_player_facing(-1.25);
|
||||
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), -1.25);
|
||||
@@ -252,22 +228,15 @@ fn a_minimap_is_born_with_nine_model_children_and_the_ninth_is_the_player_arrow(
|
||||
"Frame"
|
||||
);
|
||||
|
||||
// The ctor only *constructs* them; the model files are `CMinimap::LoadXML 0x4ee2b0`'s to
|
||||
// assign, so a Lua-built Minimap with no XML behind it has nine file-less Models — exactly as
|
||||
// the reference does.
|
||||
// The model files come from `CMinimap::LoadXML` (`0x4ee2b0`), so a Lua-built one has none.
|
||||
assert!(s
|
||||
.eval::<Option<String>>("return ({m:GetChildren()})[9]:GetModel()")
|
||||
.unwrap()
|
||||
.is_none_or(|p| p.is_empty()));
|
||||
}
|
||||
|
||||
/// **`Minimap:SetMaskTexture` is state, and an empty path restores the default rather than
|
||||
/// unmasking the map.**
|
||||
///
|
||||
/// A real 1.12 method (the name is in the 5875 image) with no getter beside it, so the readback
|
||||
/// here is through the arena — the same shape `simplehtml`'s and `modelframe`'s tests use for a
|
||||
/// write-only verb. pfUI's `modules/minimap.lua:27` is the caller that matters: swapping this art
|
||||
/// is how its square minimap becomes square.
|
||||
/// A 1.12 method with no getter, so the test reads the arena; pfUI's `modules/minimap.lua:27`
|
||||
/// squares its minimap with it.
|
||||
#[test]
|
||||
fn set_mask_texture_is_state_and_empty_restores_the_default() {
|
||||
let s = script();
|
||||
@@ -282,21 +251,17 @@ fn set_mask_texture_is_state_and_empty_restores_the_default() {
|
||||
Some("Interface\\AddOns\\pfUI\\img\\minimap")
|
||||
);
|
||||
|
||||
// Empty and nil both mean "back to the engine's circle" — never "no mask at all". A
|
||||
// nil-means-unmasked reading would hand every mistyped path a silently square minimap.
|
||||
// Empty and nil both restore the engine's circle, never "no mask".
|
||||
s.run(r#"m:SetMaskTexture("")"#).unwrap();
|
||||
assert_eq!(s.minimap_mask_texture(), None);
|
||||
s.run(r#"m:SetMaskTexture("Interface\\Foo") m:SetMaskTexture(nil)"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.minimap_mask_texture(), None);
|
||||
|
||||
// There is no getter in 1.12, and we do not invent one (decision 1189).
|
||||
assert!(s.eval::<bool>("return m.GetMaskTexture == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `frame_effective_alpha` — the host-side read behind a frame whose pixels the app draws (the
|
||||
/// stock `MiniMapPing` model, 1974): `None` while hidden, or hidden through a parent; the
|
||||
/// effective alpha while effectively visible.
|
||||
/// The host-side alpha read for a frame the app draws itself, such as the stock `MiniMapPing`.
|
||||
#[test]
|
||||
fn frame_effective_alpha_reads_the_shown_frames_alpha() {
|
||||
let s = UiScript::new().unwrap();
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! Rust-driven tests of the Lua host: the object model, layout+size reads, show/hide + event + tick
|
||||
//! firing (both `0x704d50`/`0x704f10` conventions), the WoW stdlib (positional `format`, the
|
||||
//! aliases, `getglobal`), the sandbox holes, and an end-to-end extract in ZKey order.
|
||||
//!
|
||||
//! Split by subject; the shared `script()` fixture lives in [`common`].
|
||||
//! Rust-driven tests of the Lua host, one module per subject; the shared `script()` fixture lives
|
||||
//! in [`common`].
|
||||
|
||||
mod addon_argument_abi;
|
||||
mod addon_index_space;
|
||||
@@ -58,11 +55,6 @@ mod widget_surface;
|
||||
mod worldframe;
|
||||
mod worldmap;
|
||||
|
||||
/// **A bounded chunk reports instead of hanging** — the guard decision 1247's hang called for.
|
||||
///
|
||||
/// Without it, a `while true do end` inside an addon takes the whole process with it, and every
|
||||
/// instrument downstream produces nothing at all: no roster, no columns, no error row. The bound
|
||||
/// turns that into an ordinary Lua error, which every caller already knows how to report.
|
||||
#[test]
|
||||
fn an_instruction_budget_turns_a_runaway_loop_into_an_error() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -81,9 +73,6 @@ fn an_instruction_budget_turns_a_runaway_loop_into_an_error() {
|
||||
s.instructions_used()
|
||||
);
|
||||
|
||||
// **The VM survives it.** The bound must not poison the interpreter — a survey has 217 more
|
||||
// addons to run after the one that looped, and each gets its own VM only because this one
|
||||
// returned at all.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return 2 + 3").unwrap(),
|
||||
5,
|
||||
@@ -98,16 +87,10 @@ fn an_instruction_budget_turns_a_runaway_loop_into_an_error() {
|
||||
assert_eq!(t.eval::<i64>("return BudgetOk").unwrap(), 500_500);
|
||||
}
|
||||
|
||||
/// **`GetBindLocation()` — the hearth location's name, `""` before the packet lands.**
|
||||
///
|
||||
/// Three corpus addons read it (FuBar_TransporterFu `TransporterFu.lua:456`, Necrosis
|
||||
/// `Necrosis.lua:1089`, _LazyPig `LazyPig.lua:623`), and the reference's own hearth confirmation
|
||||
/// formats it straight into the dialog (`StaticPopup.lua:1742`).
|
||||
/// The hearth location's name, `""` before the packet; `StaticPopup.lua:1742` formats it.
|
||||
#[test]
|
||||
fn get_bind_location_answers_the_pushed_name_and_never_nil() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
// Before the app pushes anything: the empty string, NOT nil. Necrosis concatenates the result,
|
||||
// so nil would be a raise rather than a blank — the same choice `GetRealmName` beside it makes.
|
||||
assert_eq!(s.eval::<String>("return GetBindLocation()").unwrap(), "");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(GetBindLocation())").unwrap(),
|
||||
@@ -120,13 +103,11 @@ fn get_bind_location_answers_the_pushed_name_and_never_nil() {
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
"Stormwind City"
|
||||
);
|
||||
// Concatenation is the idiom that matters, so it is the one asserted.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return "Bound: " .. GetBindLocation()"#)
|
||||
.unwrap(),
|
||||
"Bound: Stormwind City"
|
||||
);
|
||||
// A re-bind replaces it rather than accumulating.
|
||||
s.set_bind_location("Ironforge");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||||
@@ -134,19 +115,13 @@ fn get_bind_location_answers_the_pushed_name_and_never_nil() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`RequestTimePlayed()` queues the ask; `TIME_PLAYED_MSG` carries the answer.**
|
||||
///
|
||||
/// The two halves are one feature: `QuestHistory` calls the verb and then waits on the event, so
|
||||
/// shipping the request alone would be 1203 — an addon waiting forever is quieter and worse than
|
||||
/// the `attempt to call global` it got before.
|
||||
/// `RequestTimePlayed()` queues the ask; `TIME_PLAYED_MSG` carries the answer.
|
||||
#[test]
|
||||
fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
|
||||
// The verb returns NOTHING — the answer is an event, never a return value.
|
||||
assert_eq!(s.arity("RequestTimePlayed()").unwrap(), 0);
|
||||
// …and that call queued one ask. A COUNT, not a payload: the packet is empty, so two asks in a
|
||||
// frame are two sends rather than one collapsed intent (the pvp queue's rule).
|
||||
// A count, not a latch: the packet is empty, so two asks in a frame are two sends.
|
||||
s.run("RequestTimePlayed() RequestTimePlayed()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_played_time_asks(),
|
||||
@@ -155,7 +130,6 @@ fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() {
|
||||
);
|
||||
assert_eq!(s.take_played_time_asks(), 0, "the drain empties the queue");
|
||||
|
||||
// The answer half: the app fires the event with (total, level) in seconds.
|
||||
s.run(
|
||||
r#"
|
||||
TPSeen = nil
|
||||
@@ -179,9 +153,7 @@ fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The engine hands caught script errors to the CHOSEN Lua error handler** (decision 1305) —
|
||||
/// the reference's `seterrorhandler` contract, which is how `_ERRORMESSAGE`'s dialog (or an
|
||||
/// addon's own ImprovedErrorFrame-style handler) ever hears about a failure the engine caught.
|
||||
/// `seterrorhandler`'s contract: an engine-caught script error goes to the chosen handler.
|
||||
#[test]
|
||||
fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_duplicate() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -193,8 +165,7 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Nobody chose a handler: the error lands on the host channel ONCE, and the dispatch —
|
||||
// recognising the stdlib default by identity — adds nothing.
|
||||
// With the stdlib default handler, recognised by identity, the dispatch adds nothing.
|
||||
s.fire_event("B271_PROBE", vec![]);
|
||||
s.dispatch_script_errors_to_handler();
|
||||
let errors = s.take_errors();
|
||||
@@ -204,8 +175,7 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du
|
||||
"one error, once — the default handler must not double-report: {errors:?}"
|
||||
);
|
||||
|
||||
// An addon chose one: the dispatch fires it with the message, and the host channel STILL
|
||||
// records the error (the dual channel is what keeps the harness's instruments sighted).
|
||||
// A chosen handler gets the message, and the host channel still records it.
|
||||
s.run("caught = {} seterrorhandler(function(msg) table.insert(caught, msg) end)")
|
||||
.unwrap();
|
||||
s.fire_event("B271_PROBE", vec![]);
|
||||
@@ -226,11 +196,6 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every handler path reaches the chosen handler, not only `OnEvent`.** The widget scripts —
|
||||
/// `OnClick`, `OnValueChanged`, the edit box's and keyboard's, the tooltip's — pushed their errors
|
||||
/// onto the host channel alone, so an addon's broken click handler was a terminal line and never
|
||||
/// the dialog, while the same error from `OnEnter` did pop it (decision 1305's "every
|
||||
/// engine-caught script error", which these paths had never joined).
|
||||
#[test]
|
||||
fn a_widget_handler_error_reaches_the_chosen_error_handler() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -254,11 +219,7 @@ fn a_widget_handler_error_reaches_the_chosen_error_handler() {
|
||||
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
|
||||
}
|
||||
|
||||
/// **A listener that unregisters itself does not rob the next one** — on the engine-internal
|
||||
/// dispatch too (`fire_global`: `ADDON_LOADED` for a `LoadAddOn`, `UPDATE_FACTION` from the
|
||||
/// faction-header verbs). That walk stepped an index over the live list, so the hook-once idiom
|
||||
/// (`this:UnregisterEvent(event)` inside the handler) shifted the successor into the slot just
|
||||
/// visited and it never heard the event — the 1324 bug, fixed in `fire_event` and not here.
|
||||
/// `fire_global` carries `UPDATE_FACTION` from the faction-header verbs.
|
||||
#[test]
|
||||
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -274,8 +235,7 @@ fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_disp
|
||||
assert_eq!(s.eval::<String>("return log").unwrap(), "ab");
|
||||
}
|
||||
|
||||
/// **A handler that itself raises cannot recurse the error path** — its failure is recorded on
|
||||
/// the host channel only, never re-queued, and the batch stops on the first handler failure.
|
||||
/// The handler's own failure goes to the host channel only, never re-queued; the batch stops.
|
||||
#[test]
|
||||
fn an_error_handler_that_errors_does_not_recurse() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -300,7 +260,6 @@ fn an_error_handler_that_errors_does_not_recurse() {
|
||||
.any(|e| e.contains("error handler itself failed")),
|
||||
"and so is the handler's own failure, named as such: {errors:?}"
|
||||
);
|
||||
// A second dispatch finds an EMPTY queue — the handler's failure was never re-queued.
|
||||
s.dispatch_script_errors_to_handler();
|
||||
assert!(
|
||||
s.take_errors().is_empty(),
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
//! **The model pane's clock, arm, and two handlers** (decision 2007; `0x76d7f0`, `0x7121a0`,
|
||||
//! `0x76cac0`).
|
||||
//!
|
||||
//! A `<Model>` widget owns a private scene whose clock its own `OnUpdate` advances while the
|
||||
//! frame is visible; `SetSequence`/`SetSequenceTime` arm a sequence with an anchor the sampler
|
||||
//! re-reads; `OnUpdateModel` fires at the top of every paint and `OnAnimFinished` from the
|
||||
//! completion of a clamped sequence. Every one of those is Lua-observable — the shipped cooldown
|
||||
//! sweep is nothing but those handlers driving `SetSequenceTime` — so the law is tested here
|
||||
//! against the stock `Cooldown.lua`, transcribed verbatim.
|
||||
//!
|
||||
//! The engine parses no M2, so a file's sequences and bounds are **facts** the host hands over
|
||||
//! ([`ModelFileFacts`]); these tests hand over the facts `benilla-extract m2seq` reads off the
|
||||
//! shipped files.
|
||||
//! The model pane's clock, sequence arm and handlers (`0x76d7f0`, `0x7121a0`, `0x76cac0`). The
|
||||
//! engine parses no M2: a file's sequences and bounds are [`ModelFileFacts`] the host hands over.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{Model, UiScript};
|
||||
@@ -32,15 +21,13 @@ fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UI-Cooldown-Indicator.m2` (`m2seq`): seq 0 = id 0, 1000 ms, clamp; seq 1 = id 1, 1000 ms,
|
||||
/// clamp.
|
||||
/// `UI-Cooldown-Indicator.m2`'s sequences as `m2seq` reads them.
|
||||
const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
|
||||
fn cooldown_facts() -> ModelFileFacts {
|
||||
facts(&[(0, 1000, false), (1, 1000, false)])
|
||||
}
|
||||
|
||||
/// `MinimapPing.m2` (`m2seq`): seq 0 = id 127, 1333 ms, clamp; seq 1 = id 0, 833 ms, LOOP; seq
|
||||
/// 2 = id 1, 333 ms, clamp. `SetSequence(0)` is the looping one.
|
||||
/// `MinimapPing.m2`'s sequences as `m2seq` reads them; `SetSequence(0)` is the looping one.
|
||||
const PING_FILE: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
|
||||
fn ping_facts() -> ModelFileFacts {
|
||||
facts(&[(127, 1333, false), (0, 833, true), (1, 333, false)])
|
||||
@@ -64,9 +51,7 @@ fn play_head(s: &UiScript, name: &str, path: &str) -> Option<(u16, u32)> {
|
||||
m.play_head(facts).map(|p| (p.anim_id, p.cursor_ms))
|
||||
}
|
||||
|
||||
/// The widget's `OnUpdate` (`0x76d7f0`) advances the scene clock by `trunc(elapsed · 1000)` —
|
||||
/// only while the frame is visible (the UI pump walks visible frames), so a hidden pane's clock
|
||||
/// stands still and a re-shown pane resumes where it stopped.
|
||||
/// `OnUpdate` (`0x76d7f0`) adds `trunc(elapsed · 1000)` ms, for visible frames only.
|
||||
#[test]
|
||||
fn the_clock_runs_only_while_the_pane_is_shown_and_truncates() {
|
||||
let mut s = script();
|
||||
@@ -97,20 +82,18 @@ fn the_clock_runs_only_while_the_pane_is_shown_and_truncates() {
|
||||
"re-shown: resumes where it stopped"
|
||||
);
|
||||
|
||||
// The looping sequence wraps on its 833 ms and never completes.
|
||||
// The looping sequence wraps on its 833 ms.
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 616)));
|
||||
s.tick(0.5); // clock 1116 → 1116 mod 833
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 283)));
|
||||
}
|
||||
|
||||
/// `SetModel` runs the loader's completion (`0x70ebd0`) — arm **Stand** (id 0 if the file owns
|
||||
/// it, else `animations[0]`'s id), variation 0 — synchronously when the facts are known, and when
|
||||
/// they land otherwise; until then the pane is a waiter and the host is asked for the file.
|
||||
/// The loader's completion (`0x70ebd0`) arms Stand (id 0 if the file owns it, else
|
||||
/// `animations[0]`'s id), at `SetModel` when the facts are known or when they land.
|
||||
#[test]
|
||||
fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// Facts first: the arm is immediate.
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"a = CreateFrame("Model", "Known", UIParent) a:SetModel("{}")"#,
|
||||
@@ -126,7 +109,6 @@ fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
);
|
||||
assert_eq!(play_head(&s, "Known", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// Facts unknown: a waiter, and the file goes on the host's list once.
|
||||
s.run(&format!(
|
||||
r#"b = CreateFrame("Model", "Waiting", UIParent) b:SetModel("{0}")
|
||||
c = CreateFrame("Model", "Waiting2", UIParent) c:SetModel("{0}")"#,
|
||||
@@ -142,7 +124,6 @@ fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
);
|
||||
assert!(s.model_facts_wanted().is_empty(), "drained");
|
||||
|
||||
// The facts land: every waiter holding the file arms its Stand at its own clock.
|
||||
s.tick(0.25);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
for name in ["Waiting", "Waiting2"] {
|
||||
@@ -155,7 +136,6 @@ fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
);
|
||||
}
|
||||
|
||||
// A file that does not own id 0 seeds `animations[0]`'s own id.
|
||||
s.set_model_facts(
|
||||
r"Interface\Odd.mdx",
|
||||
facts(&[(204, 500, true), (166, 500, true)]),
|
||||
@@ -165,9 +145,7 @@ fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
assert_eq!(pane(&s, "Odd").armed.map(|a| a.anim_id), Some(204));
|
||||
}
|
||||
|
||||
/// `SetSequence(id)` with an id the file does not own **stops what was playing and arms
|
||||
/// nothing** — `0x7121a0`'s interrupt runs before its bounds check. A queued arm on a
|
||||
/// file still loading is kept until the facts say otherwise.
|
||||
/// `0x7121a0` interrupts the track before its bounds check; a queued arm waits for the facts.
|
||||
#[test]
|
||||
fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
let mut s = script();
|
||||
@@ -183,7 +161,6 @@ fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
assert_eq!(pane(&s, "P").sequence, 42, "the raw id is still recorded");
|
||||
assert!(pane(&s, "P").armed.is_none(), "…but nothing plays");
|
||||
|
||||
// Queued on an unloaded file, then refused when the facts land.
|
||||
s.run(r#"q = CreateFrame("Model", "Q", UIParent) q:SetModel("Interface\\Late.mdx") q:SetSequence(7)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -198,8 +175,7 @@ fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
own — nothing plays"
|
||||
);
|
||||
|
||||
// The other order of the same replay: a queued arm the file DOES own wins over the seed,
|
||||
// at its original offset, re-anchored on the clock the file landed on.
|
||||
// A queued arm the file owns wins over the seed, re-anchored on the landing clock.
|
||||
s.run(r#"r = CreateFrame("Model", "R", UIParent) r:SetModel("Interface\\Later.mdx") r:SetSequenceTime(5, 40)"#)
|
||||
.unwrap();
|
||||
s.tick(0.3);
|
||||
@@ -211,17 +187,13 @@ fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
assert_eq!((a.anim_id, a.armed_at_ms, a.anchor_ms), (5, 300, 300 - 40));
|
||||
}
|
||||
|
||||
/// The shipped cooldown, on the two handlers and nothing else — `Cooldown.lua` verbatim:
|
||||
/// `SetTimer` arms sequence 0 and shows; every paint `OnUpdateModel` scrubs
|
||||
/// `SetSequenceTime(0, elapsed/duration · 1000)` until done, then flips to sequence 1 at 0; the
|
||||
/// flash's completion (`OnAnimFinished`, 1000 ms later) hides the frame.
|
||||
/// The stock `Cooldown.lua`, which runs on `OnUpdateModel` and `OnAnimFinished` alone.
|
||||
#[test]
|
||||
fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
// `SetTimer`'s gate is `start > 0`: a session clock still at 0 refuses the timer, as the
|
||||
// reference's would — so the session is a second old before the button is pressed.
|
||||
// `SetTimer` gates on `start > 0`, so the session clock must be past 0.
|
||||
s.tick(1.0);
|
||||
s.run(&format!(
|
||||
r#"
|
||||
@@ -272,22 +244,18 @@ fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// The sweep: each paint scrubs sequence 0 to the elapsed fraction.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 250)));
|
||||
s.tick(1.0);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 750)));
|
||||
// A scrub is an ANCHOR, not a freeze: between two paints the clock runs on from it. The
|
||||
// cooldown never sees that (it re-scrubs every paint), the ping relies on it.
|
||||
// A scrub is an anchor, not a freeze: between paints the clock runs on from it.
|
||||
assert_eq!(pane(&s, "CD").armed.map(|a| a.anchor_ms), Some(1500 - 750));
|
||||
|
||||
// Done: the flip to the flash, sequence 1 at 0.
|
||||
s.tick(0.6);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 0)));
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(s.eval::<i64>("return cd.stopping").unwrap(), 1);
|
||||
|
||||
// The flash runs its 1000 ms; the completion fires once, and the handler hides the frame.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 500)));
|
||||
assert!(s.eval::<bool>("return table.getn(fired) == 0").unwrap());
|
||||
@@ -301,15 +269,13 @@ fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
!s.frame_visible("CD"),
|
||||
"…which the stock handler turns into Hide()"
|
||||
);
|
||||
// Hidden: no paint, no second completion, the cursor holds at the end.
|
||||
s.tick(1.0);
|
||||
assert_eq!(s.eval::<i64>("return table.getn(fired)").unwrap(), 1);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 1000)));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `OnUpdateModel` runs with `this` set, once per paint of a **visible** pane, before the
|
||||
/// completion is read — so a handler that re-arms in the same paint completes nothing.
|
||||
/// `OnUpdateModel` runs before the completion is read, so re-arming each paint completes nothing.
|
||||
#[test]
|
||||
fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
let mut s = script();
|
||||
@@ -342,7 +308,6 @@ fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
2,
|
||||
"no paint while hidden"
|
||||
);
|
||||
// Re-arming every paint keeps the clamped sequence from ever completing.
|
||||
s.run("M:Show() M:SetScript(\"OnUpdateModel\", function() this:SetSequenceTime(1, 0) end)")
|
||||
.unwrap();
|
||||
for _ in 0..30 {
|
||||
@@ -351,9 +316,8 @@ fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A LOOPING sequence fires `OnAnimFinished` once too — at the end of its first pass — and keeps
|
||||
/// looping (`0x719370` enqueues the completion before it tests the loop flag). And a pane with no
|
||||
/// file fires no `OnUpdateModel` at all (`76d24c`'s gate), however visible.
|
||||
/// `0x719370` enqueues the completion before it tests the loop flag, so a loop's first pass fires
|
||||
/// `OnAnimFinished`; a pane with no file fails `0x76d24c`'s gate and never paints.
|
||||
#[test]
|
||||
fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
let mut s = script();
|
||||
@@ -409,9 +373,8 @@ fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The implicit rect** (decision 2015): a model pane that authored no size takes its file's
|
||||
/// bounding-box extent in LAYOUT units — `768·√(a²+1)` FrameXML units per unit, `1280` at 4:3
|
||||
/// — the moment the facts are known; it follows the screen's aspect; an authored size wins.
|
||||
/// A pane with no authored size takes its file's bounding box in layout units, `768·√(a²+1)`
|
||||
/// FrameXML units each (1280 at 4:3), following the screen's aspect.
|
||||
#[test]
|
||||
fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
let mut s = script();
|
||||
@@ -463,7 +426,7 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
assert_eq!((bw, bh), (50.0, 20.0), "an authored size is untouched");
|
||||
assert!(pane(&s, "Sized").implicit_size && !pane(&s, "Authored").implicit_size);
|
||||
|
||||
// 16:9 — a layout unit is 768·√((16/9)²+1) = 1566.4 FrameXML units.
|
||||
// At 16:9 a layout unit is 768·√((16/9)²+1) = 1566.4 FrameXML units.
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.resolve();
|
||||
let w: f32 = s.eval("return Sized:GetWidth()").unwrap();
|
||||
@@ -477,9 +440,7 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
assert_eq!(s.eval::<f32>("return Sized:GetWidth()").unwrap(), 10.0);
|
||||
}
|
||||
|
||||
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
|
||||
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
|
||||
/// `ClearModel`.
|
||||
/// The type-14 texture override belongs to the model instance and dies with it.
|
||||
#[test]
|
||||
fn replace_icon_texture_lives_and_dies_with_the_instance() {
|
||||
let mut s = script();
|
||||
@@ -523,8 +484,7 @@ fn replace_icon_texture_lives_and_dies_with_the_instance() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The XML `scale=` on a model pane is the MODEL's scale (`0x76cac0` → `+0x3a0`), never the
|
||||
/// frame's — the cooldown template's 0.75 must not shrink the widget's rect.
|
||||
/// A model pane's XML `scale=` is the model's scale (`0x76cac0`, `+0x3a0`), never the frame's.
|
||||
#[test]
|
||||
fn the_model_scale_attribute_is_the_models_own() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
//! **The model-pane family's Lua surface** — `Model` and `PlayerModel`, read back through the API
|
||||
//! that wrote them.
|
||||
//!
|
||||
//! The property under test throughout is that these widgets are *state the app renders*, not state
|
||||
//! the engine interprets: every setter's value must come back out unchanged, and the places where
|
||||
//! the widget does have an opinion (one yaw slot written by two verbs on two classes; content being
|
||||
//! an either/or) must hold.
|
||||
//!
|
||||
//! Plus the one structural property, guarded as a whole block:
|
||||
//! [`the_two_model_tables_are_the_references_own`] asserts our surfaces against the reference's
|
||||
//! enumerated tables in **both** directions, so neither a missing verb nor an invented one can slip
|
||||
//! past — and so `Model` can never re-acquire the three that are `PlayerModel`'s.
|
||||
//! The `Model` and `PlayerModel` Lua surface, read back through the API that wrote it, and checked
|
||||
//! against the reference's method tables in both directions.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// The whole scene, set and read back — plus the widget's three actual behaviours: `SetRotation`
|
||||
/// and `SetFacing` are one slot, `SetModel` and `SetUnit` displace each other, and `ClearModel`
|
||||
/// empties both.
|
||||
#[test]
|
||||
fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
let mut s = script();
|
||||
@@ -24,8 +11,7 @@ fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
s.run(r#"m = CreateFrame("Model", "MPane", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// A fresh pane: no content, unit SCALE (not zero — a model at scale 0 is invisible, which is
|
||||
// why ModelState hand-writes its Default), no yaw, at the origin.
|
||||
// A fresh pane: no model, scale 1 (not 0, which would draw nothing), no yaw.
|
||||
assert_eq!(
|
||||
s.eval::<(Option<String>, f64, f64)>(
|
||||
"return MPane:GetModel(), MPane:GetModelScale(), MPane:GetFacing()"
|
||||
@@ -35,8 +21,7 @@ fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
"a fresh pane has no model and unit scale"
|
||||
);
|
||||
|
||||
// The path round-trips verbatim — the client's own path space, backslashes and `.mdx` intact.
|
||||
// pfUI's autocast shine is exactly this call.
|
||||
// The path round-trips verbatim, backslashes and `.mdx` intact (pfUI's autocast shine).
|
||||
s.run(r#"MPane:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -44,12 +29,10 @@ fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
r"Interface\Buttons\UI-AutoCastButton.mdx"
|
||||
);
|
||||
|
||||
// The yaw. `SetFacing` is the `Model` verb for it (`0x878948[4]`); `SetRotation` writes the
|
||||
// same field but belongs to `PlayerModel` and is tested there.
|
||||
// `SetFacing` is the `Model` yaw verb (`0x878948[4]`).
|
||||
s.run("MPane:SetFacing(-0.25)").unwrap();
|
||||
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), -0.25);
|
||||
|
||||
// Scale, camera, position — plain storage, read back through their own getters.
|
||||
s.run("MPane:SetModelScale(0.4) MPane:SetCamera(2) MPane:SetPosition(0.1, -0.2, 3)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -63,8 +46,6 @@ fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
"GetPosition returns the three numbers SetPosition took"
|
||||
);
|
||||
|
||||
// `SetModel(nil)` is the documented clear and reaches the same place `ClearModel` does — not
|
||||
// everything in the corpus calls the dedicated verb.
|
||||
s.run("MPane:ClearModel()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
|
||||
@@ -72,16 +53,9 @@ fn the_model_pane_holds_the_scene_it_was_given() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A `<PlayerModel>` is a `<Model>` plus exactly three verbs — and the inheritance runs one way.**
|
||||
///
|
||||
/// The client registers four model-pane types and each has its own Lua method table that never
|
||||
/// repeats its base's; a derived pane reaches its base through the miss leg of `vtable+0x8`
|
||||
/// (`0x7020b0`). So `PlayerModel`'s three-entry table `0x84f1fc` must sit *over* `Model`'s 23-entry
|
||||
/// `0x878948`, and nothing may chain the other way: `CSimpleModel`'s lookup `0x76f870` has no leg
|
||||
/// into `CGCharacterModelBase`'s `0x506260`.
|
||||
///
|
||||
/// This is the shape pfUI's unit frames need — `CreateFrame("PlayerModel", ...)` driven by
|
||||
/// `SetUnit` + `SetCamera`, one verb from each table on the same frame.
|
||||
/// Each model-pane type has its own method table and reaches its base's through the miss leg of
|
||||
/// `vtable+0x8` (`0x7020b0`): `PlayerModel`'s three (`0x84f1fc`) sit over `Model`'s 23
|
||||
/// (`0x878948`), and `CSimpleModel`'s lookup `0x76f870` has no leg into `0x506260`.
|
||||
#[test]
|
||||
fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
let mut s = script();
|
||||
@@ -94,7 +68,6 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Its own three resolve on the PlayerModel...
|
||||
for verb in ["SetUnit", "RefreshUnit", "SetRotation"] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(PMPane.{verb})"))
|
||||
@@ -102,8 +75,6 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
"function",
|
||||
"PlayerModel must answer its own {verb}"
|
||||
);
|
||||
// ...and on the plain Model they are ABSENT. This is the assertion that would have caught
|
||||
// three years of `SetUnit` published on the wrong class.
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(MOnly.{verb})"))
|
||||
.unwrap(),
|
||||
@@ -112,7 +83,7 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
);
|
||||
}
|
||||
|
||||
// ...and the base's verbs resolve through the chain, unrepeated. pfUI's portrait line.
|
||||
// pfUI's portrait line: `SetUnit`, then the base's `SetCamera` through the chain.
|
||||
s.run(r#"PMPane:SetUnit("player") PMPane:SetCamera(0)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -129,8 +100,7 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
"...and back the other way, the direction an addon reskinning a paper doll takes"
|
||||
);
|
||||
|
||||
// `SetRotation` writes THE SAME yaw field `SetFacing` does — `0x505bb0`'s last instruction is
|
||||
// `mov [esi+0x39c], eax`, and `+0x39c` is what `0x76dce0` writes. `GetFacing` reads either.
|
||||
// `SetRotation` (`0x505bb0`) and `SetFacing` (`0x76dce0`) write the same yaw field, `+0x39c`.
|
||||
s.run("PMPane:SetRotation(1.5)").unwrap();
|
||||
assert_eq!(s.eval::<f64>("return PMPane:GetFacing()").unwrap(), 1.5);
|
||||
s.run("PMPane:SetFacing(-0.25)").unwrap();
|
||||
@@ -140,12 +110,11 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
"one slot: SetFacing overwrites what SetRotation wrote"
|
||||
);
|
||||
|
||||
// RefreshUnit is a live no-op: the pane stores the unit TOKEN and resolves it at render, so
|
||||
// there is no cached appearance to invalidate. It must still exist — the reference's own
|
||||
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
|
||||
// `RefreshUnit` is a no-op, as the pane resolves its unit token at render; stock
|
||||
// `PaperDollFrame.xml:221` calls it.
|
||||
assert!(s.run("PMPane:RefreshUnit()").is_ok());
|
||||
|
||||
// ClearModel — a `Model` verb reached through the chain — empties BOTH content slots.
|
||||
// `ClearModel`, reached through the chain, empties both content slots.
|
||||
s.run(r#"PMPane:SetUnit("player") PMPane:ClearModel()"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -155,19 +124,8 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetModel(nil)` raises; `ClearModel()` clears. They are not the same verb.**
|
||||
///
|
||||
/// This binding asserted the opposite until 2026-08-30 — "the documented clear, and how the corpus
|
||||
/// writes 'no model'" — which was invented, not read. `0x76d950` is shape A (decision 1717's
|
||||
/// taxonomy): `lua_isstring` gates the argument and `Usage: %s:SetModel("file")` is raised on
|
||||
/// anything that is not a string or a number. `ClearModel 0x76db20` is a separate table entry.
|
||||
///
|
||||
/// The same gate on `ReplaceIconTexture 0x76ed70`, whose *whole* observable behaviour it is: the
|
||||
/// swap itself lands on a `CM2Model` we do not have, and with no CM2Model the reference drops the
|
||||
/// call and never replays it.
|
||||
///
|
||||
/// A NUMBER is accepted by both — `lua_isstring` takes tags 3|4 and the client renders it to
|
||||
/// decimal text — which is the half a `Value::String`-only match would get wrong.
|
||||
/// `SetModel` (`0x76d950`) and `ReplaceIconTexture` (`0x76ed70`) gate on `lua_isstring`, raising
|
||||
/// their usage string on anything but a string or a number; `ClearModel` (`0x76db20`) is the clear.
|
||||
#[test]
|
||||
fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
|
||||
let mut s = script();
|
||||
@@ -183,26 +141,23 @@ fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
|
||||
"SetModel({bad}) must raise — it is not the clear"
|
||||
);
|
||||
}
|
||||
// ...and none of those raises disturbed the path that was already set.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GateM:GetModel()").unwrap(),
|
||||
r"Interface\Buttons\A.mdx",
|
||||
"a raised setter leaves the pane alone"
|
||||
);
|
||||
// A number IS a string to `lua_isstring`, and the client renders it to decimal text.
|
||||
// A number passes `lua_isstring` and is stored as its decimal text.
|
||||
s.run("GateM:SetModel(42)").unwrap();
|
||||
assert_eq!(s.eval::<String>("return GateM:GetModel()").unwrap(), "42");
|
||||
|
||||
// ClearModel is the clear, takes no argument, and empties the pane.
|
||||
s.run("GateM:ClearModel()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Option<String>>("return GateM:GetModel()").unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// `ReplaceIconTexture` — same gate, and that gate is all of it. A good argument is accepted
|
||||
// and changes NOTHING observable: the swap targets the CM2Model's type-14 textures, and a
|
||||
// pane with no CM2Model drops the call (the reference's own `[widget+0x318] == 0` leg).
|
||||
// `ReplaceIconTexture` swaps the CM2Model's type-14 textures, and a pane with none
|
||||
// (`[widget+0x318] == 0`) drops the call, so only the gate is observable.
|
||||
for bad in ["nil", "{}", "true", ""] {
|
||||
assert!(
|
||||
s.run(&format!("GateM:ReplaceIconTexture({bad})")).is_err(),
|
||||
@@ -219,7 +174,7 @@ fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
|
||||
"it is a MATERIAL swap, not a content setter — the pane's model is untouched"
|
||||
);
|
||||
|
||||
// `AdvanceTime` takes nothing, returns nothing, and does nothing — verified, not stubbed.
|
||||
// The reference's `AdvanceTime` takes nothing, returns nothing and does nothing.
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return table.getn({ GateM:AdvanceTime() })")
|
||||
.unwrap(),
|
||||
@@ -228,25 +183,11 @@ fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The whole block, both directions** — decision 1718's rule applied to the two model tables.
|
||||
///
|
||||
/// The lists below are the reference's OWN enumerations, transcribed entry-for-entry from `Model`'s
|
||||
/// table `0x878948` and `PlayerModel`'s `0x84f1fc` (each table's count fixed twice: the registering
|
||||
/// `mov edx, imm32`, and the dword at `base + 8*count` being the start of the string pool). They
|
||||
/// are **not** the names anyone noticed were missing — that is exactly the mistake 1718 records,
|
||||
/// and it was re-made inside the test written to enforce it.
|
||||
///
|
||||
/// Guarding both directions is the point:
|
||||
///
|
||||
/// - **no gap** — every reference name we claim to publish must resolve;
|
||||
/// - **no superset** — a name the reference does not have on a table must not resolve there
|
||||
/// (1189: a name we have and the reference lacks routes an addon down a path the real client
|
||||
/// never takes);
|
||||
/// - **the `UNBUILT` set must stay absent** — it is empty since 2027, so "unbuilt" cannot
|
||||
/// quietly become "stubbed" without this test being edited to say so.
|
||||
/// The reference's own method tables, entry for entry (`Model` `0x878948`, `PlayerModel`
|
||||
/// `0x84f1fc`), checked both ways: every name resolves, and a name a table lacks does not.
|
||||
#[test]
|
||||
fn the_two_model_tables_are_the_references_own() {
|
||||
/// `Model` — `CSimpleModel`, table `0x878948`, 23 entries, in table order.
|
||||
/// `Model`, `CSimpleModel`'s table `0x878948`, in table order.
|
||||
const MODEL_23: [&str; 23] = [
|
||||
"SetModel",
|
||||
"GetModel",
|
||||
@@ -272,14 +213,9 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
"GetFogFar",
|
||||
"ClearFog",
|
||||
];
|
||||
/// `PlayerModel` — `CGCharacterModelBase`, table `0x84f1fc`, 3 entries, in table order.
|
||||
/// `PlayerModel`, `CGCharacterModelBase`'s table `0x84f1fc`, in table order.
|
||||
const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"];
|
||||
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4).
|
||||
/// **Empty since decision 2027** — the last five were the fog near/far set, held back only
|
||||
/// because `ClearFog`'s effect on the colour/near/far triple was uncarved;
|
||||
/// `76f5c5 and [edi+0x3a4],-2` shows bit 0 alone, so the guess is gone and the verbs are
|
||||
/// real. Kept as an empty array rather than deleted: the wall below is the thing that
|
||||
/// notices when a name arrives or leaves.
|
||||
/// The names in [`MODEL_23`] this client leaves unbuilt, absent rather than stubbed: none.
|
||||
const UNBUILT: [&str; 0] = [];
|
||||
|
||||
let mut s = script();
|
||||
@@ -299,7 +235,7 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
|
||||
for name in MODEL_23 {
|
||||
let want = !UNBUILT.contains(&name);
|
||||
// A `Model` verb resolves on BOTH panes — on the PlayerModel through the chain.
|
||||
// A `Model` verb resolves on both panes, on the PlayerModel through the chain.
|
||||
for frame in ["GuardM", "GuardPM"] {
|
||||
assert_eq!(
|
||||
is_fn(&s, frame, name),
|
||||
@@ -318,9 +254,7 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
"GuardM.{name}: 0x84f1fc is NOT reachable from CSimpleModel's lookup"
|
||||
);
|
||||
}
|
||||
// The two names a `strings` scan of the model band would tempt anyone into, which do not exist
|
||||
// in 5875 in ANY form (substring scan of the mapped image returns 0, positive control 27 hits
|
||||
// for `Creature`). They are later-expansion verbs; publishing one is decision 1189's error.
|
||||
// Later-expansion verbs, absent from the 1.12 client image in any form.
|
||||
for name in ["SetCreature", "SetCustomRace"] {
|
||||
for frame in ["GuardM", "GuardPM"] {
|
||||
assert!(
|
||||
@@ -331,11 +265,8 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SetSequence` and `SetSequenceTime` are one arm** (`0x7121a0`): each interrupts what plays
|
||||
/// and anchors the new sequence's cursor — at 0, or at the caller's `ms` — on the pane's own
|
||||
/// clock. The clock law itself (advance, wrap, completion, the two handlers) is
|
||||
/// `script::tests::model_clock`; this is the binding-level shape, with no file facts known, which
|
||||
/// is the state the reference's queued replay covers.
|
||||
/// `SetSequence` and `SetSequenceTime` share one arm (`0x7121a0`): each interrupts what plays and
|
||||
/// anchors the new sequence's cursor, at 0 or at the caller's `ms`, on the pane's own clock.
|
||||
#[test]
|
||||
fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
let mut s = script();
|
||||
@@ -350,8 +281,7 @@ fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1.12 has no `GetSequence`, so the arm is read off the model the way `simplehtml`'s tests
|
||||
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
||||
// 1.12 has no `GetSequence`, so the arm is read off the arena.
|
||||
let armed = |s: &UiScript| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
@@ -373,19 +303,14 @@ fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
"a new sequence starts at its own 0 — the old anchor is not carried across"
|
||||
);
|
||||
|
||||
// ...and ClearModel releases the instance, arm included.
|
||||
// `ClearModel` releases the instance, arm included.
|
||||
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
||||
.unwrap();
|
||||
assert_eq!(armed(&s).1, None);
|
||||
}
|
||||
|
||||
/// `SetLight` writes the widget's embedded `CGLight` by the reference's own argument walk
|
||||
/// (`0x76e1e0`), and `GetLight` reads it back at arity **7 | 10 | 13**.
|
||||
///
|
||||
/// This test is the law, not a round trip: the tuple used to be stored verbatim because nobody had
|
||||
/// read the binding at the bytes, and "what went in comes out" is the one thing the reference does
|
||||
/// NOT do — the intensities are folded into the colours, the direction is normalised, and both
|
||||
/// traps change what a call means.
|
||||
/// `SetLight` writes the pane's `CGLight` by the reference's argument walk (`0x76e1e0`), folding
|
||||
/// intensities into colours and normalising the direction; `GetLight` answers 7, 10 or 13 values.
|
||||
#[test]
|
||||
fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
let mut s = script();
|
||||
@@ -393,8 +318,7 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
s.run(r#"m = CreateFrame("Model", "MLight", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// A fresh pane: the ctor's white, switched off. Both colour blocks are `> 0`, so both come
|
||||
// back as `1.0, r, g, b` — arity 13 before anything has ever been set.
|
||||
// The constructor's light is off and white; both colour blocks are non-zero, so arity 13.
|
||||
let fresh: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(
|
||||
fresh,
|
||||
@@ -402,19 +326,17 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
"the <Model> ctor: disabled, omni, at the origin, white ambient and diffuse"
|
||||
);
|
||||
|
||||
// The commented-out GlueXML line, which is the corpus's only example of the shape.
|
||||
// The commented-out call at `CharacterCreate.lua:61`.
|
||||
s.run("MLight:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1, 1, 1, 0.8, 1, 1, 0.8)")
|
||||
.unwrap();
|
||||
let l: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(l.len(), 13);
|
||||
assert_eq!((l[0], l[1]), (1.0, 0.0), "enabled, directional");
|
||||
// The direction is NORMALISED on write (`0x71b6a0`), so `(0, -0.707, -0.707)` comes back as
|
||||
// a unit vector — the one place a naive round trip would disagree.
|
||||
// The direction is normalised on write (`0x71b6a0`).
|
||||
let dir = [l[2], l[3], l[4]];
|
||||
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-5, "normalised on write: {dir:?}");
|
||||
// The intensity is FOLDED into the colour: ambient `1,1,1` at `0.7` is `(0.7, 0.7, 0.7)`,
|
||||
// and the reported intensity is a constant 1.0.
|
||||
// The intensity is folded into the colour, so ambient `1,1,1` at 0.7 reads 0.7 at intensity 1.
|
||||
assert_eq!(l[5], 1.0);
|
||||
for c in &l[6..9] {
|
||||
assert!((c - 0.7).abs() < 1e-5, "ambient folded: {c}");
|
||||
@@ -424,17 +346,14 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
assert!((c - want).abs() < 1e-5, "diffuse folded: {c} vs {want}");
|
||||
}
|
||||
|
||||
// TRAP 1: `SetLight(0, …)` returns before the local is copied, so it changes NOTHING — it is
|
||||
// not a way to switch a light off, and a `<PlayerModel>`'s built-in one cannot be killed.
|
||||
// `SetLight(0, …)` returns before the copy: a no-op, not a way to switch a light off.
|
||||
s.run("MLight:SetLight(0, 1, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1)")
|
||||
.unwrap();
|
||||
let after: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(after, l, "SetLight(0, …) is a no-op, not a disable");
|
||||
|
||||
// TRAP 2: an ambient intensity of 0 makes the walk skip its colour triple WITHOUT advancing
|
||||
// the cursor, so the second intensity is read at index 8 — the seven-argument form. Here
|
||||
// `1, 1, 1` are the ambient rgb the walk never reaches, and `0.5` is index 8 = the diffuse
|
||||
// intensity against a white colour.
|
||||
// A zero ambient intensity skips its colour triple without advancing the cursor, so the next
|
||||
// argument (stack index 8) is the diffuse intensity: 0.5, against a white colour.
|
||||
s.run("MLight:SetLight(1, 1, 0, 0, 0, 0, 0.5)").unwrap();
|
||||
let t2: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(t2.len(), 10, "a zero ambient block collapses the arity");
|
||||
@@ -448,11 +367,8 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
assert!(s.run(r#"MLight:SetLight(1, 0, 0, 0, 0)"#).is_err());
|
||||
assert!(s.run(r#"MLight:SetLight("x")"#).is_err());
|
||||
|
||||
// **Fog is NOT the same shape, and this used to assert the opposite.** It read "unset fog
|
||||
// returns NOTHING rather than three zeros — a pane with no fog and a pane fogged to black are
|
||||
// different states", which is a good argument about a model the client does not have: there is
|
||||
// no unset state. The fog colour is one packed `0xAARRGGBB` dword whose ctor writes
|
||||
// `0xffffffff`, so a fresh pane reads **four** values, `1, 1, 1, 1` (decision 1845).
|
||||
// The fog colour is one packed `0xAARRGGBB` dword, `0xffffffff` from the constructor, so a
|
||||
// fresh pane reads four values, all 1; there is no unset state.
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return table.getn({ MLight:GetFogColor() })")
|
||||
.unwrap(),
|
||||
@@ -465,9 +381,7 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
"never set is white and opaque, not four zeros"
|
||||
);
|
||||
|
||||
// Three arguments set alpha to **1.0**, not 0 — the fifth is guarded with that default where
|
||||
// r/g/b are read unconditionally. The round trip is LOSSY by 8 bits a channel, because the
|
||||
// store is that packed dword: 0.1 does not survive, 0.2 does.
|
||||
// With three arguments alpha defaults to 1.0; the packed store keeps 8 bits a channel.
|
||||
s.run("MLight:SetFogColor(0.1, 0.2, 0.3)").unwrap();
|
||||
let (r, g, b, a): (f64, f64, f64, f64) = s.eval("return MLight:GetFogColor()").unwrap();
|
||||
assert_eq!(a, 1.0, "the omitted alpha defaults to 1.0");
|
||||
@@ -478,7 +392,7 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
);
|
||||
}
|
||||
|
||||
// …and the alpha really is the fifth argument, on the same clamp as the rest.
|
||||
// Alpha is the fourth argument, clamped like the rest.
|
||||
s.run("MLight:SetFogColor(1, 1, 1, 0)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("local _, _, _, a = MLight:GetFogColor() return a")
|
||||
@@ -487,13 +401,9 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetCamera(n)` selects by **raw table index**, is bounds-checked against the file's camera
|
||||
/// count, defers until the file's facts land, and gates the paint until the question is settled —
|
||||
/// the four halves of `0x76cec0`/`0x76ce80`/`0x76ce00`/`76d5f0` (decision 2027).
|
||||
///
|
||||
/// The whole point of the state is which render leg a pane takes: an installed camera is the
|
||||
/// perspective leg, the NULL camera is the orthographic one, and an index past the count is a
|
||||
/// NULL camera rather than an error or a clamp to the last record.
|
||||
/// `SetCamera(n)` picks by raw index, checked against the file's camera count once the file is
|
||||
/// known, and the pane waits to draw until then (`0x76cec0`, `0x76ce80`, `0x76ce00`, `0x76d5f0`).
|
||||
/// A camera draws in perspective; none, or an index past the count, draws orthographic.
|
||||
#[test]
|
||||
fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
use super::model_clock::pane;
|
||||
@@ -518,23 +428,21 @@ fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MCam", UIParent) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||||
.unwrap();
|
||||
// The ctor's standing request for camera 0 (`0x76c910` writes `+0x320 = 0`), still pending
|
||||
// because the file's facts have not arrived — so the pane is NOT on the paint list.
|
||||
// The constructor's request for camera 0 (`0x76c910` writes `+0x320 = 0`) waits for the file.
|
||||
assert_eq!(state(&s, "MCam"), (Some(0), None));
|
||||
assert!(s.visible_model_panes().is_empty(), "the draw gate holds");
|
||||
|
||||
// The facts land: the model-ready hook applies the pending index. Wolf has two cameras, so
|
||||
// index 0 installs and the pane takes the perspective leg.
|
||||
// The file lands with two cameras, so the pending index 0 installs: perspective.
|
||||
s.set_model_facts(FILE, facts(2));
|
||||
assert_eq!(state(&s, "MCam"), (None, Some(0)));
|
||||
assert_eq!(s.visible_model_panes().len(), 1, "the gate is settled");
|
||||
|
||||
// With the facts in hand `SetCamera` resolves immediately — raw index 1 is a real record.
|
||||
// With the file known, `SetCamera` resolves at once.
|
||||
s.run("MCam:SetCamera(1)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, Some(1)));
|
||||
|
||||
// An index past the count installs the NULL camera: the ORTHOGRAPHIC leg, and the pane keeps
|
||||
// drawing. Not a clamp to the last record, not a raise.
|
||||
// An index past the count installs no camera, not the last one, and the pane draws on,
|
||||
// orthographic.
|
||||
s.run("MCam:SetCamera(7)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, None));
|
||||
assert_eq!(s.visible_model_panes().len(), 1);
|
||||
@@ -542,8 +450,7 @@ fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
s.run("MCam:SetCamera(1) MCam:SetCamera(-1)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, None));
|
||||
|
||||
// A file with no camera table at all: every index is out of range, so a plain <Model> on it
|
||||
// is always the orthographic leg — which is every shipped in-game UI M2.
|
||||
// A file with no cameras, like every shipped UI M2, always draws orthographic.
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MFlat", UIParent) m:SetModel("Interface\\Cooldown\\UI-Cooldown-Indicator.mdx")"#)
|
||||
@@ -551,8 +458,7 @@ fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
s.set_model_facts(r"Interface\Cooldown\UI-Cooldown-Indicator.mdx", facts(0));
|
||||
assert_eq!(state(&s, "MFlat"), (None, None));
|
||||
|
||||
// `SetCamera` before the file is known DEFERS, and the deferred index is what the facts
|
||||
// apply — bounds-checked then, not at the call.
|
||||
// `SetCamera` before the file is known defers, and the index is checked when the file lands.
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MLate", UIParent) m:SetCamera(1) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||||
@@ -563,9 +469,8 @@ fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
assert_eq!(state(&s, "MLate"), (None, Some(1)));
|
||||
}
|
||||
|
||||
/// The fog block: `SetFogColor` **arms** it, `ClearFog` disarms **bit 0 alone**, and near/far are
|
||||
/// stored raw by the Lua setters (`0x76ee60`/`0x76f540`, decision 2027). The five verbs held back
|
||||
/// as uncarved since 1134 §4 are real now.
|
||||
/// `SetFogColor` arms the fog, `ClearFog` clears bit 0 alone (`0x76f5c5`), and the Lua setters
|
||||
/// store near and far raw (`0x76ee60`/`0x76f540`).
|
||||
#[test]
|
||||
fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||||
use super::model_clock::pane;
|
||||
@@ -579,8 +484,7 @@ fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MFog", UIParent)"#)
|
||||
.unwrap();
|
||||
// The ctor: fog OFF, near 0.0, far **1.0** (`76c950`) — not far 0, which would be a
|
||||
// degenerate ramp the batch would refuse.
|
||||
// The constructor's fog: off, near 0, far 1 (`0x76c950`).
|
||||
assert_eq!(fog(&s), (false, 0.0, 1.0));
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return MFog:GetFogNear(), MFog:GetFogFar()")
|
||||
@@ -588,14 +492,13 @@ fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||||
(0.0, 1.0)
|
||||
);
|
||||
|
||||
// The glue's own call shape. `SetFogColor` is the arming verb; near/far are not.
|
||||
// `GlueParent.lua:213-215` with the Tauren row (`:18`); only `SetFogColor` arms the fog.
|
||||
s.run("MFog:SetFogNear(0) MFog:SetFogFar(153)").unwrap();
|
||||
assert_eq!(fog(&s), (false, 0.0, 153.0), "near/far do not arm the fog");
|
||||
s.run("MFog:SetFogColor(1.0, 0.61, 0.42)").unwrap();
|
||||
assert!(fog(&s).0, "the colour arms it");
|
||||
|
||||
// `ClearFog` touches bit 0 and nothing else: the colour, the near and the far all survive, so
|
||||
// a later SetFogColor re-arms the same ramp.
|
||||
// `ClearFog` keeps the colour, near and far, so a later `SetFogColor` re-arms the same ramp.
|
||||
s.run("MFog:ClearFog()").unwrap();
|
||||
assert_eq!(fog(&s), (false, 0.0, 153.0));
|
||||
let (r, g, b, _): (f64, f64, f64, f64) = s.eval("return MFog:GetFogColor()").unwrap();
|
||||
@@ -604,8 +507,7 @@ fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||||
"the colour survives a clear: {r}, {g}, {b}"
|
||||
);
|
||||
|
||||
// The Lua setters store RAW — no clamp, no ordering check. Only the XML attribute path
|
||||
// clamps at `>= 0`.
|
||||
// The Lua setters store raw, with no clamp or ordering check; only the XML path clamps at 0.
|
||||
s.run("MFog:SetFogNear(-40) MFog:SetFogFar(-1)").unwrap();
|
||||
assert_eq!(fog(&s), (false, -40.0, -1.0));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! The movable-frame family — `SetMovable`/`StartMoving`/`StopMovingOrSizing`/`SetUserPlaced`/
|
||||
//! `SetResizable` (the mechanism and its addresses are in `script::object::movable`).
|
||||
//!
|
||||
//! Every test here drives the PRODUCTION path: the Lua bindings, and the real
|
||||
//! `mouse_button`/`mouse_move` entry points, so the drag gesture that fires `OnDragStart` is the
|
||||
//! same one an addon gets.
|
||||
//! The movable-frame family (`SetMovable`, `StartMoving`, `StopMovingOrSizing`, `SetUserPlaced`,
|
||||
//! `SetResizable`), title regions and the user-placed layout cache, driven through the Lua
|
||||
//! bindings and the real mouse entry points.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
@@ -38,16 +35,13 @@ fn corner(s: &mut UiScript) -> (f32, f32) {
|
||||
(left, bottom)
|
||||
}
|
||||
|
||||
/// The whole point of the family: the four-line addon idiom actually moves the frame, and the
|
||||
/// position it was dragged to SURVIVES the stop — nothing snaps it back to the `SetPoint` it was
|
||||
/// born with, and nothing keeps moving it after the button is up.
|
||||
#[test]
|
||||
fn the_canonical_addon_idiom_moves_a_frame_and_the_position_survives_the_stop() {
|
||||
let mut s = movable_panel();
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0), "born where SetPoint put it");
|
||||
|
||||
s.mouse_button(150.0, 140.0, "LeftButton", true); // press inside the panel
|
||||
s.mouse_move(152.0, 140.0); // 2 px — under the drag threshold, nothing yet
|
||||
s.mouse_move(152.0, 140.0); // 2 px, under the drag threshold
|
||||
assert_eq!(s.eval::<i64>("return starts").unwrap(), 0);
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0));
|
||||
|
||||
@@ -64,27 +58,23 @@ fn the_canonical_addon_idiom_moves_a_frame_and_the_position_survives_the_stop()
|
||||
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
|
||||
assert_eq!(corner(&mut s), (200.0, 150.0), "the position survives");
|
||||
|
||||
// And the move is really over: further cursor motion moves nothing.
|
||||
s.mouse_move(500.0, 400.0);
|
||||
assert_eq!(corner(&mut s), (200.0, 150.0), "no longer following");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
// GetPoint reads back the frame's OWN point with the dragged offsets — the reference writes
|
||||
// the anchors in place rather than rewriting them, so what an addon saves is what it set.
|
||||
// The reference moves the anchors in place, so GetPoint reads the frame's own point, dragged.
|
||||
let (point, x, y) = s
|
||||
.eval::<(String, f64, f64)>("local p, _, _, x, y = MovePanel:GetPoint() return p, x, y")
|
||||
.unwrap();
|
||||
assert_eq!((point.as_str(), x, y), ("BOTTOMLEFT", 200.0, 150.0));
|
||||
}
|
||||
|
||||
/// A moving frame follows SUCCESSIVE moves, each one applying only that step's delta (the pump
|
||||
/// re-centers its sample — a bug that integrated from the grab instead would double the second
|
||||
/// step, and one that forgot to re-center would freeze after the first).
|
||||
/// Each move applies only its own delta: the pump re-centers its sample after every step.
|
||||
#[test]
|
||||
fn a_moving_frame_follows_successive_mouse_moves() {
|
||||
let mut s = movable_panel();
|
||||
s.mouse_button(150.0, 140.0, "LeftButton", true);
|
||||
s.mouse_move(160.0, 140.0); // the move that STARTS the drag
|
||||
s.mouse_move(160.0, 140.0); // the move that starts the drag
|
||||
assert_eq!(
|
||||
corner(&mut s),
|
||||
(100.0, 100.0),
|
||||
@@ -102,9 +92,7 @@ fn a_moving_frame_follows_successive_mouse_moves() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `StartMoving` on a frame that is not `SetMovable(true)` RAISES and moves nothing — the
|
||||
/// reference's own guard (`0x776700`'s movable-bit test). The error is a real Lua error, so an
|
||||
/// addon's `pcall` sees it.
|
||||
/// The reference's `StartMoving` raises on a frame that is not movable (`0x776700`).
|
||||
#[test]
|
||||
fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() {
|
||||
let mut s = script();
|
||||
@@ -128,13 +116,11 @@ fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() {
|
||||
"the refusal names why"
|
||||
);
|
||||
|
||||
// Nothing is in flight, so the cursor moves nothing.
|
||||
s.mouse_move(400.0, 400.0);
|
||||
s.resolve();
|
||||
let left = s.eval::<f64>("return FixedPanel:GetLeft()").unwrap();
|
||||
assert_eq!(left, 100.0, "a refused StartMoving started no move");
|
||||
|
||||
// The same frame, once made movable, moves — proving the guard was the only thing stopping it.
|
||||
s.run("FixedPanel:SetMovable(true) FixedPanel:StartMoving()")
|
||||
.unwrap();
|
||||
s.mouse_move(450.0, 400.0);
|
||||
@@ -142,10 +128,8 @@ fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() {
|
||||
assert_eq!(s.eval::<f64>("return FixedPanel:GetLeft()").unwrap(), 150.0);
|
||||
}
|
||||
|
||||
/// `StopMovingOrSizing` with nothing moving is harmless — the double call, the OnDragStop that
|
||||
/// never had a StartMoving, the addon that also wires it to OnMouseUp. And it only stops the
|
||||
/// frame that is actually in the drag slot (the reference's `[root+0xcfc] == self` compare), so
|
||||
/// one frame's stop cannot end another's move.
|
||||
/// `StopMovingOrSizing` is harmless with nothing moving, and stops only the frame in the drag slot
|
||||
/// (the reference compares `[root+0xcfc]` with self).
|
||||
#[test]
|
||||
fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_frame() {
|
||||
let mut s = movable_panel();
|
||||
@@ -163,7 +147,6 @@ fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0), "nothing moved");
|
||||
|
||||
// Now start a real move and have the OTHER frame try to stop it.
|
||||
s.run("MovePanel:StartMoving()").unwrap();
|
||||
s.mouse_move(50.0, 50.0); // cursor_pos was (0,0) ⇒ +50, +50
|
||||
assert_eq!(corner(&mut s), (150.0, 150.0));
|
||||
@@ -180,10 +163,7 @@ fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A frame stretched between two anchors — no explicit size at all — moves RIGIDLY and keeps its
|
||||
/// derived size. This is the case a single-point rewrite gets wrong (it would drop the opposing
|
||||
/// anchor and leave the frame under-constrained, i.e. gone), and the case that decides between
|
||||
/// translating one anchor and translating the set.
|
||||
/// A move translates every anchor, so a frame sized by two anchors keeps its size.
|
||||
#[test]
|
||||
fn a_frame_stretched_between_two_anchors_moves_rigidly() {
|
||||
let mut s = script();
|
||||
@@ -219,8 +199,8 @@ fn a_frame_stretched_between_two_anchors_moves_rigidly() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A SCALED frame moves with the cursor 1:1 on screen — the anchor offsets it writes are in local
|
||||
/// units, so they carry the inverse of the frame's own scale (`0x768710`'s `dx/scale`).
|
||||
/// A scaled frame tracks the cursor 1:1 on screen: the offsets it writes are in local units
|
||||
/// (`0x768710` divides by the frame's scale).
|
||||
#[test]
|
||||
fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() {
|
||||
let mut s = script();
|
||||
@@ -244,7 +224,7 @@ fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() {
|
||||
);
|
||||
s.mouse_move(40.0, 0.0);
|
||||
s.resolve();
|
||||
// GetLeft reports LOCAL units: 40 screen px is 20 local, on top of the local 100.
|
||||
// GetLeft reports local units: 40 screen px is 20 local, on top of the local 100.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ScaledPanel:GetLeft()").unwrap(),
|
||||
120.0
|
||||
@@ -256,9 +236,8 @@ fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The flags: default off, round-trip through their setters, and `SetUserPlaced`'s guard — the
|
||||
/// reference refuses it unless the frame is movable OR resizable (`776adb: test ah,0x3`).
|
||||
/// `StartMoving` sets the bit itself, which is the reference's drag-start doing it, not us.
|
||||
/// The reference refuses `SetUserPlaced` unless the frame is movable or resizable (`0x776adb`),
|
||||
/// and its drag start sets the bit itself (`0x7652b0`).
|
||||
#[test]
|
||||
fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
|
||||
let mut s = script();
|
||||
@@ -282,7 +261,6 @@ fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
|
||||
"no frame is born flagged"
|
||||
);
|
||||
|
||||
// SetUserPlaced before either flag: refused, exactly like the reference.
|
||||
assert!(
|
||||
!s.eval::<bool>("return (pcall(function() FlagPanel:SetUserPlaced(true) end))")
|
||||
.unwrap(),
|
||||
@@ -305,7 +283,6 @@ fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
|
||||
"truthy 1 sets, false clears"
|
||||
);
|
||||
|
||||
// The drag start sets userPlaced itself (`0x7652b0`).
|
||||
s.resolve();
|
||||
s.run("FlagPanel:StartMoving()").unwrap();
|
||||
assert!(
|
||||
@@ -316,9 +293,6 @@ fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `movable="true"` / `resizable="true"` in XML land on the real methods — they were a warn-once
|
||||
/// gap in the loader until this family existed, which left every reference window authored
|
||||
/// movable undraggable while `SetMovable` worked fine from Lua.
|
||||
#[test]
|
||||
fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
|
||||
let s = script();
|
||||
@@ -349,17 +323,9 @@ fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`StartSizing(grip)` moves the gripped edges and plants the opposite ones.**
|
||||
///
|
||||
/// `0x776830`; the reference's own caller is
|
||||
/// `FloatingChatFrame.lua:600`. Four corpus addons reach it through ONE line —
|
||||
/// `FuBar_Panel.lua:980`, replicated into FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2
|
||||
/// (1207: one library, not four votes).
|
||||
///
|
||||
/// What is not settled anywhere is which edges a grip moves — the verb only orchestrates, with no
|
||||
/// inline math. Taken here as the plain meaning of an anchor point, which is how the reference's
|
||||
/// caller uses it. This test is where that reading is pinned, so a finding that contradicts it
|
||||
/// fails here first.
|
||||
/// `StartSizing(grip)` (`0x776830`, called from `FloatingChatFrame.lua:600`) moves the gripped
|
||||
/// edges and plants the opposite ones. Which edges a grip moves is untraced in the reference, whose
|
||||
/// verb has no inline math; this takes the anchor point's plain meaning, as that caller does.
|
||||
#[test]
|
||||
fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -375,7 +341,7 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Grip the RIGHT edge and drag 40 right: width grows, the left edge stays planted.
|
||||
// Grip the right edge and drag 40 right: the width grows, the left edge stays planted.
|
||||
s.mouse_move(300.0, 100.0);
|
||||
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
|
||||
s.mouse_move(340.0, 100.0);
|
||||
@@ -384,8 +350,7 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
|
||||
// Grip the LEFT edge and drag 30 right: width SHRINKS and the left edge follows the cursor,
|
||||
// so the right edge is the one that stays put.
|
||||
// Grip the left edge and drag 30 right: the width shrinks and the right edge stays put.
|
||||
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
|
||||
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
|
||||
s.mouse_move(370.0, 100.0);
|
||||
@@ -398,13 +363,11 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
"the ungripped edge must not move"
|
||||
);
|
||||
|
||||
// StopMovingOrSizing ends it: further motion changes nothing.
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
s.mouse_move(500.0, 100.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
|
||||
|
||||
// A frame that is not resizable refuses, like the movable family's siblings.
|
||||
s.run("g = CreateFrame(\"Frame\", \"NotSizer\", UIParent)")
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -413,9 +376,7 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`CreateTitleRegion` / `GetTitleRegion` — the object half** (`0x773910` / `0x773820`).
|
||||
///
|
||||
/// Four details, each one a coin-flip a reimplementation loses, and each one asserted.
|
||||
/// `CreateTitleRegion` (`0x773910`) and `GetTitleRegion` (`0x773820`): the object half.
|
||||
#[test]
|
||||
fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
let mut s = script();
|
||||
@@ -429,15 +390,14 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// GetTitleRegion answers ONE value and it is nil — not zero values, which is the asymmetry
|
||||
// against `GetBackdrop 0x777370`.
|
||||
// With none, it returns one nil, where `GetBackdrop` (`0x777370`) returns no value at all.
|
||||
assert_eq!(s.arity("TFrame:GetTitleRegion()").unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<Option<bool>>("return TFrame:GetTitleRegion() ~= nil and true or nil")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// It is a plain REGION — not a Texture wearing a hat.
|
||||
// A plain Region, not a Texture.
|
||||
s.run("TR = TFrame:CreateTitleRegion()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TR:GetObjectType()").unwrap(),
|
||||
@@ -455,14 +415,12 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
.eval::<bool>("return TFrame:GetTitleRegion() == TR")
|
||||
.unwrap());
|
||||
|
||||
// **It takes NO argument**: `CreateTitleRegion(frame)` is the no-arg call, which is exactly
|
||||
// what `CustomNameplates/options.lua:73` writes.
|
||||
// It takes no argument: `CreateTitleRegion(frame)` is the no-argument call.
|
||||
assert!(s
|
||||
.eval::<bool>("return TFrame:CreateTitleRegion(TFrame) == TR")
|
||||
.unwrap());
|
||||
|
||||
// **Idempotent, and DESTRUCTIVELY so.** A second call returns the same object after clearing
|
||||
// its anchors — so it silently wipes the points a caller already set.
|
||||
// A second call returns the same region with its anchors cleared.
|
||||
s.run("TR:SetAllPoints(TFrame)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return TR:GetNumPoints()").unwrap(), 2);
|
||||
@@ -475,10 +433,8 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
"the second call ran ClearAllPoints on the region it returned"
|
||||
);
|
||||
|
||||
// **It answers the 19 Region methods and NOTHING else** — 1250 §5's named divergence, closed.
|
||||
// (`0x81c554`/`0x81c528`): no Show/Hide, no scripts, no textures. Ours used to share one
|
||||
// metatable with Texture and FontString, so a title region answered `SetTexture` where the
|
||||
// reference raises.
|
||||
// It answers the 19 Region methods and nothing else (`0x81c554`, `0x81c528`): no Show/Hide,
|
||||
// no scripts, no textures.
|
||||
for name in crate::script::REGION_MAP_METHODS {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(TR.{name})"))
|
||||
@@ -521,8 +477,7 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
"a title region emits no quad of its own (the frame's own quad is not one): {region_quads:?}"
|
||||
);
|
||||
|
||||
// The full region kinds are untouched by the split — asserted LAST, because creating a real
|
||||
// texture would otherwise put a legitimate quad into the check above.
|
||||
// Checked last: a real texture would add a legitimate quad to the check above.
|
||||
s.run("TTex = TFrame:CreateTexture(nil, 'ARTWORK')")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -532,10 +487,8 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The drag half — and the three things that make it not just `StartMoving`.**
|
||||
///
|
||||
/// A mouse-down inside the title region starts a move that (1) SWALLOWS `OnMouseDown`, (2) ends on
|
||||
/// release where a scripted move persists, and (3) does not consult `SetMovable`.
|
||||
/// A press in the title region starts a move that, unlike `StartMoving`, swallows `OnMouseDown`,
|
||||
/// ends on release and skips the movable check.
|
||||
#[test]
|
||||
fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
let mut s = script();
|
||||
@@ -559,7 +512,6 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
};
|
||||
assert_eq!(corner(&mut s), 100.0);
|
||||
|
||||
// Press inside the title region: the move starts and OnMouseDown never runs.
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return downs").unwrap(),
|
||||
@@ -569,7 +521,7 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
s.mouse_move(250.0, 150.0);
|
||||
assert_eq!(corner(&mut s), 200.0, "the frame followed the cursor");
|
||||
|
||||
// **Release ends it** — mode 2 auto-cancels. Moving the cursor afterwards must not drag it.
|
||||
// Release ends it: the title-region move (mode 2) cancels itself.
|
||||
s.mouse_button(250.0, 150.0, "LeftButton", false);
|
||||
s.mouse_move(400.0, 150.0);
|
||||
assert_eq!(
|
||||
@@ -578,16 +530,12 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
"the move ended at the release; a scripted StartMoving would still be running"
|
||||
);
|
||||
|
||||
// **No `SetMovable` gate on this path** — TP was never made movable, and it dragged. The
|
||||
// movable bit is simply not read on 0x7662c0->0x765320->0x7652b0->0x768430; a title region on a
|
||||
// non-movable frame is the case that tells it apart from `StartMoving`, which raises "Frame %s
|
||||
// is not movable".
|
||||
// The movable bit is not read on this path (`0x7662c0`, `0x765320`, `0x7652b0`, `0x768430`).
|
||||
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
|
||||
}
|
||||
|
||||
/// A region's rect getters answer in its OWNER's units — screen ÷ the owner's effective scale —
|
||||
/// exactly as the frame getters do (decision 1985): a texture inside a frame scaled to 0.5 that
|
||||
/// covers the frame answers the frame's own width, not half of it.
|
||||
/// A region's rect getters answer in its owner's units, screen ÷ the owner's effective scale, as
|
||||
/// the frame getters do.
|
||||
#[test]
|
||||
fn region_getters_answer_in_the_owners_units_under_scale() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -616,14 +564,9 @@ fn region_getters_answer_in_the_owners_units_under_scale() {
|
||||
assert!((cx - (fl + fr) * 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// **The layout cache's filter is the flags AND the bit, at both ends** (decision 2193).
|
||||
///
|
||||
/// `SetUserPlaced` is already guarded by `movable|resizable` at its own setter (`0x776adb`), but
|
||||
/// the drag entry (`0x7652b0` @`0x7652e5`) and the cache's own apply stamp the bit without going
|
||||
/// through it — so a frame can carry the stamp while carrying neither flag, and the reference's
|
||||
/// writer tests for both: `0x490e8e test ah,0x10` (userPlaced) AND `0x490e97 test ah,0x3`
|
||||
/// (`movable|resizable`). Clearing the flags is how a window stops being persisted, which is what
|
||||
/// an addon's `:OnDisable` does on the way out.
|
||||
/// The layout cache writes a frame only when it is user-placed (`0x490e8e`) and movable or
|
||||
/// resizable (`0x490e97`): the drag start (`0x7652b0` at `0x7652e5`) and the cache's own apply
|
||||
/// set the bit past `SetUserPlaced`'s guard (`0x776adb`), so clearing the flags stops the save.
|
||||
#[test]
|
||||
fn the_write_filter_is_user_placed_and_movable_or_resizable() {
|
||||
let mut s = script();
|
||||
@@ -659,11 +602,9 @@ fn the_write_filter_is_user_placed_and_movable_or_resizable() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The apply is gated per ARM** (decision 2193): position behind `movable` (`0x490600 test
|
||||
/// ah,0x1`), size behind `resizable` (`0x490689 test ah,0x2`), and each arm stamps the userPlaced
|
||||
/// bit itself (`0x49067e` / `0x490706`) only if it ran. A stock frame carrying neither flag is
|
||||
/// left entirely alone, however old its row — which is what stops one addon's stamp from seating
|
||||
/// a window forever.
|
||||
/// The cache's apply seats position only when movable (`0x490600`) and size only when resizable
|
||||
/// (`0x490689`), and each arm that runs sets userPlaced (`0x49067e`, `0x490706`); a frame with
|
||||
/// neither flag is left alone.
|
||||
#[test]
|
||||
fn the_apply_seats_position_behind_movable_and_size_behind_resizable() {
|
||||
use crate::script::{FrameLayout, LayoutPoint};
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
//! **A widget NAME argument is a Lua GLOBAL, not a frame name** — the one rule behind every
|
||||
//! binding that takes "a frame or its name": `SetPoint`'s `relativeTo`, `SetAllPoints`,
|
||||
//! `SetParent`, `SetScrollChild`.
|
||||
//!
|
||||
//! The client has a single widget namespace and it is `_G`. The wrapper binder `0x701bd0`
|
||||
//! publishes `_G[name] = T` for a named widget (non-overwriting), and the by-name resolvers read
|
||||
//! that table back and nothing else — `0x76c760` (FrameScript globals **rawget** + Lua-type-5 +
|
||||
//! the `vtbl+0x10` tag check) and its geometry-vtable `+0x28` twin `0x76c700`
|
||||
//! (`_G[name]` → `t[0]` userdata → `IsA`).
|
||||
//!
|
||||
//! A frame's published name is therefore only the commonest way such a global comes to exist —
|
||||
//! **an alias is just as good a name**, and 1.12 addons lean on that. Bartender2 2.0 aliases every
|
||||
//! stock button (`Bar8Button1 = CharacterBag3Slot`, file scope, `Alias.lua`) and then lays each bar
|
||||
//! out by anchoring button *i* to the string `"Bar8Button"..(i-1)`. Resolving those against a
|
||||
//! private frame-name registry finds nothing, every anchor falls back to the parent, and all five
|
||||
//! bag buttons land in one spot on the bar's far corner. Decision 2105.
|
||||
//! A widget name argument (`SetPoint`'s `relativeTo`, `SetAllPoints`, `SetParent`,
|
||||
//! `SetScrollChild`) is a Lua global, so an alias resolves too: the binder `0x701bd0` publishes
|
||||
//! `_G[name]` without overwriting, and the resolvers `0x76c760` (rawget, type 5, `vtbl+0x10` tag)
|
||||
//! and `0x76c700` (`_G[name]`, `t[0]`, `IsA`) read `_G` and nothing else.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Bartender2's exact shape: stock buttons, addon alias globals that no frame is named, a row laid
|
||||
/// out through those aliases alone. The row must come out at the addon's pitch.
|
||||
/// Bartender2's shape: a row laid out through alias globals that are no frame's own name.
|
||||
#[test]
|
||||
fn setpoint_relative_to_resolves_an_alias_global() {
|
||||
let mut s = script();
|
||||
@@ -73,8 +60,7 @@ fn setpoint_relative_to_resolves_an_alias_global() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The namespace is ONE: a **region** published under a global resolves as a `relativeTo` exactly
|
||||
/// as a frame does (the real XML anchors frames to FontStrings by name — gossip option rows).
|
||||
/// One namespace: stock XML anchors frames to FontStrings by name (the gossip option rows).
|
||||
#[test]
|
||||
fn setpoint_relative_to_resolves_a_region_alias_global() {
|
||||
let mut s = script();
|
||||
@@ -106,9 +92,7 @@ fn setpoint_relative_to_resolves_a_region_alias_global() {
|
||||
assert_eq!(bottom, 20.0);
|
||||
}
|
||||
|
||||
/// A global that is not a widget resolves to nothing — the reference's type-5 + tag check — and
|
||||
/// the binding then takes its unresolved-name leg, which **raises** (`0x87ccd4`; decision 2176).
|
||||
/// The value is never used, and no anchor is left behind.
|
||||
/// The type-5 and tag check reject it, and the unresolved-name leg raises (`0x87ccd4`).
|
||||
#[test]
|
||||
fn setpoint_relative_to_ignores_a_non_widget_global() {
|
||||
let mut s = script();
|
||||
@@ -136,11 +120,9 @@ fn setpoint_relative_to_ignores_a_non_widget_global() {
|
||||
assert_eq!(n, 0, "the raise leaves no anchor behind");
|
||||
}
|
||||
|
||||
/// **`$parent` is expanded at RUNTIME, on the anchor path only.** `0x76c5b0` has exactly two call
|
||||
/// sites — `SetName 0x76c691` and `0x76c71c` *inside* the layout resolver `0x76c700` — so
|
||||
/// `SetPoint`/`SetAllPoints` run the token against the anchoring frame's first **named** ancestor
|
||||
/// (skipping anonymous links, `"Top"` when there is none), while `SetParent` and `SetScrollChild`,
|
||||
/// which call `0x76c760` directly, do not.
|
||||
/// The expander `0x76c5b0` is called only from `SetName` (`0x76c691`) and the layout resolver
|
||||
/// (`0x76c71c` in `0x76c700`), so anchors expand `$parent` against the first named ancestor
|
||||
/// (`"Top"` if none); `SetParent` and `SetScrollChild` call `0x76c760` directly and do not.
|
||||
#[test]
|
||||
fn setpoint_relative_to_expands_parent_against_the_first_named_ancestor() {
|
||||
let mut s = script();
|
||||
@@ -174,8 +156,6 @@ fn setpoint_relative_to_expands_parent_against_the_first_named_ancestor() {
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// The other half of the same fact: the reparent bindings never see the token, so a
|
||||
/// `$parent`-prefixed name reaches them literally and fails to resolve.
|
||||
#[test]
|
||||
fn setparent_does_not_expand_parent() {
|
||||
let s = script();
|
||||
@@ -196,8 +176,7 @@ fn setparent_does_not_expand_parent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetParent` takes the same namespace (`0x7a1550`'s NAME-string path → `0x76c760`) — Bartender2
|
||||
/// re-parents every stock button by alias before it anchors it.
|
||||
/// `SetParent`'s name path (`0x7a1550`) resolves through `0x76c760` too.
|
||||
#[test]
|
||||
fn setparent_resolves_an_alias_global() {
|
||||
let s = script();
|
||||
@@ -217,7 +196,6 @@ fn setparent_resolves_an_alias_global() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetAllPoints` shares the resolver, so it shares the rule.
|
||||
#[test]
|
||||
fn setallpoints_resolves_an_alias_global() {
|
||||
let mut s = script();
|
||||
@@ -242,15 +220,8 @@ fn setallpoints_resolves_an_alias_global() {
|
||||
assert_eq!((l, b, w, h), (30.0, 40.0, 120.0, 60.0));
|
||||
}
|
||||
|
||||
/// **A leading `$parent` in a Lua constructor's NAME argument is expanded**, exactly as in XML —
|
||||
/// `CreateFrame 0x7060b0`, `CreateTexture 0x773a20` and `CreateFontString 0x773c30` all build a
|
||||
/// synthetic node carrying `name=` and read it back through `CScriptRegion::SetName 0x76c650`,
|
||||
/// which is one of the expander `0x76c5b0`'s two call sites.
|
||||
///
|
||||
/// Found by decision 2176's raise, which is the point of a raise: the unexpanded name had been
|
||||
/// there all along, degrading quietly into a parent-anchored fallback. `FonzAppraiser` names
|
||||
/// roughly thirty widgets this way and `pfQuest/browser.lua:723` builds its search icon as
|
||||
/// `input:CreateTexture("$parentSearchIcon", "OVERLAY")`.
|
||||
/// `CreateFrame 0x7060b0`, `CreateTexture 0x773a20` and `CreateFontString 0x773c30` read their
|
||||
/// `name=` back through `CScriptRegion::SetName 0x76c650`, which calls the expander `0x76c5b0`.
|
||||
#[test]
|
||||
fn a_constructor_name_expands_parent() {
|
||||
let s = script();
|
||||
@@ -268,7 +239,7 @@ fn a_constructor_name_expands_parent() {
|
||||
"RootKid",
|
||||
"the base is the first NAMED ancestor of the frame's parent, not the parent itself"
|
||||
);
|
||||
// …and the expanded name is what reached `_G`, which is the only widget namespace (2105).
|
||||
// The expanded name is what reached `_G`.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return RootKid:GetName()").unwrap(),
|
||||
"RootKid"
|
||||
@@ -278,12 +249,12 @@ fn a_constructor_name_expands_parent() {
|
||||
.unwrap(),
|
||||
"the raw token names nothing"
|
||||
);
|
||||
// A region's `$parent` is its OWNER frame, so the walk starts one link lower.
|
||||
// A region's `$parent` is its owner frame.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return RootKidIcon:GetName()").unwrap(),
|
||||
"RootKidIcon"
|
||||
);
|
||||
// The payoff: an anchor by the built name resolves instead of raising.
|
||||
// An anchor by the built name resolves.
|
||||
s.run(r#"Icon:SetPoint("TOPLEFT", "RootKid", "TOPLEFT", 0, 0)"#)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
//! The nameplate ABI, as the four vanilla addons actually read it (decision 2148).
|
||||
//!
|
||||
//! These are not "does the pool work" tests. Each is a transcription of a real corpus idiom — the
|
||||
//! same walk, the same destructuring, the same identification test — so a change that would break
|
||||
//! a shipped addon fails **here** rather than on the director's screen.
|
||||
//!
|
||||
//! Sources, all `## Interface: 11200`: `pfUI/modules/nameplates.lua` (+ `compat/vanilla.lua`),
|
||||
//! `ShaguTweaks/libs/libnameplate.lua`, `CustomNameplates/CustomNameplates.lua`,
|
||||
//! `_Nameplates/_Nameplates.lua`. The byte side is read off the reference binary.
|
||||
//! The nameplate ABI as four vanilla addons read it, one idiom per test:
|
||||
//! `pfUI/modules/nameplates.lua`, `ShaguTweaks/libs/libnameplate.lua`,
|
||||
//! `CustomNameplates/CustomNameplates.lua` and `_Nameplates/_Nameplates.lua`.
|
||||
|
||||
use crate::script::{PlateGeometry, PlateState, UiScript};
|
||||
|
||||
/// A VM with a `WorldFrame` to hang plates off — the only thing [`UiScript::sync_nameplates`]
|
||||
/// requires of a session.
|
||||
/// A VM with a `WorldFrame` to hang plates off, all [`UiScript::sync_nameplates`] requires.
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
@@ -21,8 +14,7 @@ fn vm() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// The plate size at the capture window (`vplates` scenario: one gx unit = 1280 px, so the
|
||||
/// 0.1 × 0.025 plate is 128 × 32 px, and at seam scale 1.0 that is 128 × 32 units).
|
||||
/// A 1280 px window, where one gx unit is 1280 px, so the 0.1 × 0.025 plate is 128 × 32.
|
||||
fn geometry() -> PlateGeometry {
|
||||
PlateGeometry {
|
||||
width: 128.0,
|
||||
@@ -43,8 +35,7 @@ fn geometry() -> PlateGeometry {
|
||||
|
||||
fn plate(name: &str, health: f32, max: f32) -> PlateState {
|
||||
PlateState {
|
||||
// The unit's GUID stands in as a hash of its name: what matters to these tests is that the
|
||||
// same unit keeps the same key across frames and two units never share one.
|
||||
// A hash of the name stands in for the GUID: stable per unit, distinct between units.
|
||||
key: name.bytes().map(u64::from).sum(),
|
||||
top_centre: (500.0, 400.0),
|
||||
health,
|
||||
@@ -67,11 +58,8 @@ fn drive(s: &mut UiScript, states: &[PlateState]) {
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// pfUI + ShaguTweaks share one lineage: `GetNumChildren` grew, so walk the tail.
|
||||
///
|
||||
/// `initialized` is never reset in either addon, so a client whose child list SHRANK or REORDERED
|
||||
/// would make them miss every plate created after the old high-water mark — permanently, and with
|
||||
/// no error. This is the test that a retired plate keeps its slot.
|
||||
/// pfUI and ShaguTweaks walk only the children past a count they never reset, so the list must not
|
||||
/// shrink or reorder: a retired plate keeps its slot.
|
||||
#[test]
|
||||
fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
|
||||
let mut s = vm();
|
||||
@@ -89,7 +77,7 @@ fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
|
||||
2
|
||||
);
|
||||
|
||||
// One unit leaves: the plate is HIDDEN, and the child list does not shrink.
|
||||
// One unit leaves: its plate hides, and the list keeps its length.
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
@@ -101,7 +89,7 @@ fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// A third unit arrives: it takes the pooled plate back — the count goes to 2, not 3.
|
||||
// A new unit takes the pooled plate back: the count stays 2.
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Wolf", 30.0, 40.0), plate("Boar", 5.0, 50.0)],
|
||||
@@ -113,12 +101,8 @@ fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
|
||||
assert_eq!(s.nameplate_pool_len(), 2);
|
||||
}
|
||||
|
||||
/// pfUI's `IsNamePlate` (`nameplates.lua:70`) and ShaguTweaks' (`libnameplate.lua:12`), verbatim in
|
||||
/// shape: the object type is `"Button"`, and region #1 is a `Texture` whose `GetTexture()` is
|
||||
/// exactly `Interface\Tooltips\Nameplate-Border`.
|
||||
///
|
||||
/// `CustomNameplates` runs the texture half against **every** WorldFrame child
|
||||
/// (`CustomNameplates.lua:93`), so `GetRegions()` must also be safe to call on a frame with none.
|
||||
/// pfUI's `IsNamePlate` (`nameplates.lua:70`) and ShaguTweaks' (`libnameplate.lua:12`): a `Button`
|
||||
/// whose first region is a `Texture` reading `Interface\Tooltips\Nameplate-Border`.
|
||||
#[test]
|
||||
fn a_plate_identifies_by_button_plus_the_border_texture() {
|
||||
let mut s = vm();
|
||||
@@ -137,12 +121,8 @@ fn a_plate_identifies_by_button_plus_the_border_texture() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The two tuples, positionally — the ABI decision 2148 §3 records.
|
||||
///
|
||||
/// `CustomNameplates.lua:212` binds `Border, Glow, Name, Level, Boss, RaidTargetIcon`; pfUI and
|
||||
/// ShaguTweaks bind the same six by the `NAMEPLATE_OBJECTORDER` table. A **seventh** region is a
|
||||
/// bug (pfUI blanks any extra with `SetTexture("")`), and a **second** child is a bug too (pfUI
|
||||
/// destructures `healthbar, castbar` and would blank ours as a TBC cast bar).
|
||||
/// Six regions in `CustomNameplates.lua:212`'s order (`Border, Glow, Name, Level, Boss,
|
||||
/// RaidTargetIcon`) and one child; pfUI blanks a seventh region, and a second child as a cast bar.
|
||||
#[test]
|
||||
fn the_region_and_child_tuples_are_six_and_one() {
|
||||
let mut s = vm();
|
||||
@@ -172,8 +152,8 @@ fn the_region_and_child_tuples_are_six_and_one() {
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
// ShaguTweaks `libnameplate.lua:39`: `plate.healthbar = plate:GetChildren()` — one child, and
|
||||
// it is the StatusBar. Its own single region is the fill `GetStatusBarTexture()` hands back.
|
||||
// `libnameplate.lua:39` takes the one child as the health bar: a StatusBar whose only region
|
||||
// is its fill.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
@@ -188,10 +168,8 @@ fn the_region_and_child_tuples_are_six_and_one() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `_Nameplates.Nameplate.Initialize` (`_Nameplates.lua:163`) is the strictest consumer: it
|
||||
/// content-addresses the regions and **validates the two FontString anchors**, rejecting any plate
|
||||
/// whose name is not `BOTTOM`→`CENTER` and whose level is not `CENTER`→`BOTTOMRIGHT`. Those are the
|
||||
/// reference's own (ctor `0x7cb456`, bind `0x7cb7f5`).
|
||||
/// `_Nameplates.lua:163` rejects a plate unless its name is anchored `BOTTOM` to `CENTER` and its
|
||||
/// level `CENTER` to `BOTTOMRIGHT`, the reference's own anchors (`0x7cb456`, `0x7cb7f5`).
|
||||
#[test]
|
||||
fn the_two_fontstring_anchors_are_what_nameplates_validates() {
|
||||
let mut s = vm();
|
||||
@@ -214,12 +192,8 @@ fn the_two_fontstring_anchors_are_what_nameplates_validates() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The bar reports **raw** health, not a fraction — `GetValue 0x78f5d0` → `[bar+0x320]`, and
|
||||
/// `GetMinMaxValues` → `(0, maxHealth)`. pfUI (`:595`) and CustomNameplates (`:471`) both divide,
|
||||
/// so a fraction here would read as full health on every plate.
|
||||
///
|
||||
/// The internal fill *fraction* `0x783380` is a distinct value, easily confused for the Lua getter
|
||||
/// above.
|
||||
/// `GetValue` (`0x78f5d0`) is raw health, `[bar+0x320]`, not the fill fraction (`0x783380`), and
|
||||
/// `GetMinMaxValues` is `(0, maxHealth)`; pfUI (`:595`) and CustomNameplates (`:471`) divide them.
|
||||
#[test]
|
||||
fn the_healthbar_reports_raw_health_and_its_max() {
|
||||
let mut s = vm();
|
||||
@@ -235,8 +209,7 @@ fn the_healthbar_reports_raw_health_and_its_max() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The plate is anonymous. `_Nameplates.lua:164` rejects any WorldFrame child with a name, and
|
||||
/// pfUI's bubble filter does the same — a named plate is invisible to both.
|
||||
/// `_Nameplates.lua:164` and pfUI's bubble filter skip any WorldFrame child with a name.
|
||||
#[test]
|
||||
fn a_plate_is_anonymous() {
|
||||
let mut s = vm();
|
||||
@@ -246,9 +219,8 @@ fn a_plate_is_anonymous() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The pooling contract: a retired plate comes back as the **same Lua object**. Every addon keys
|
||||
/// its per-plate state off exactly that identity — pfUI's `registry[plate]` (`:342`),
|
||||
/// `_Nameplates.Frames[Nameplate]` (`:205`) — and re-runs `OnCreate` for an object it has not seen.
|
||||
/// Addons key per-plate state on the plate object: pfUI's `registry[plate]` (`:342`) and
|
||||
/// `_Nameplates.Frames[Nameplate]` (`:205`).
|
||||
#[test]
|
||||
fn a_retired_plate_returns_as_the_same_lua_object() {
|
||||
let mut s = vm();
|
||||
@@ -261,14 +233,8 @@ fn a_retired_plate_returns_as_the_same_lua_object() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// **A plate belongs to its UNIT, not to this frame's sort order.**
|
||||
///
|
||||
/// The driver hands plates over in distance-sorted order — the seat priority the reference solves
|
||||
/// overlap in — and that order changes whenever two units cross. If the pool were indexed by it,
|
||||
/// widget #1 would swap units mid-session, and every addon that caches state on the frame would be
|
||||
/// reading the other unit's: pfUI's `nameplate.original.name`/`level`, its `registry[plate]`
|
||||
/// bookkeeping, `_Nameplates`' per-plate button. So the pool is keyed by the unit's GUID, and this
|
||||
/// is the test that says so.
|
||||
/// The pool is keyed by the unit's GUID, not by the driver's distance order, which changes when two
|
||||
/// units cross; addons cache per-unit state on the plate.
|
||||
#[test]
|
||||
fn a_plate_belongs_to_its_unit_across_a_sort_order_swap() {
|
||||
let mut s = vm();
|
||||
@@ -288,8 +254,7 @@ fn a_plate_belongs_to_its_unit_across_a_sort_order_swap() {
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return WolfPlate ~= nil").unwrap());
|
||||
|
||||
// The two units cross: the driver now sorts Bear first. Nothing about either plate's identity
|
||||
// may move with it.
|
||||
// The units cross: the driver now sorts Bear first.
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Bear", 10.0, 90.0), plate("Wolf", 30.0, 40.0)],
|
||||
@@ -304,8 +269,8 @@ fn a_plate_belongs_to_its_unit_across_a_sort_order_swap() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `plate:GetAlpha() == 1` is how pfUI (`:879`) and CustomNameplates (`:287`) detect the TARGET
|
||||
/// plate: with a target up, every other plate is dimmed. So alpha is a contract, not a look.
|
||||
/// pfUI (`:879`) and CustomNameplates (`:287`) find the target plate as the one at alpha 1; with a
|
||||
/// target up, every other plate is dimmed.
|
||||
#[test]
|
||||
fn the_target_plate_is_the_opaque_one() {
|
||||
let mut s = vm();
|
||||
@@ -321,9 +286,8 @@ fn the_target_plate_is_the_opaque_one() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// pfUI hooks the bar's `OnValueChanged` (`:393`) and reads `this:GetParent()` inside the handler
|
||||
/// to find the plate. The driver moves that value from Rust, so the handler has to fire from there
|
||||
/// too — the reference's own bar is driven by a GUID-watch callback and fires it the same way.
|
||||
/// pfUI's bar `OnValueChanged` (`:393`) finds the plate by `this:GetParent()`; the reference sets
|
||||
/// the bar from a GUID-watch callback that fires the handler, as the driver's write does here.
|
||||
#[test]
|
||||
fn an_engine_health_write_fires_onvaluechanged_with_the_plate_as_parent() {
|
||||
let mut s = vm();
|
||||
@@ -349,10 +313,8 @@ fn an_engine_health_write_fires_onvaluechanged_with_the_plate_as_parent() {
|
||||
assert_eq!(s.eval::<i64>("return Fired").unwrap(), 1);
|
||||
}
|
||||
|
||||
/// **An addon takes the plate over.** pfUI blanks all six regions (`DisableObject` →
|
||||
/// `SetTexture("")`) and paints its own art; ShaguTweaks reparents the raid icon. A driver that
|
||||
/// re-stamped every property every frame would undo that at 60 Hz — so static properties are
|
||||
/// written once, at creation, and never again.
|
||||
/// The driver writes a plate's static properties once, at creation, so an addon's takeover (pfUI
|
||||
/// blanks all six regions, ShaguTweaks reparents the raid icon) survives.
|
||||
#[test]
|
||||
fn the_driver_does_not_overwrite_an_addons_takeover() {
|
||||
let mut s = vm();
|
||||
@@ -366,7 +328,7 @@ fn the_driver_does_not_overwrite_an_addons_takeover() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Several frames of ordinary driving — health moving, the unit renamed, the plate re-seated.
|
||||
// Several frames of driving: health moves and the plate re-seats.
|
||||
for hp in [29.0, 28.0, 27.0] {
|
||||
let mut state = plate("Wolf", hp, 40.0);
|
||||
state.top_centre = (500.0 + hp, 400.0);
|
||||
@@ -382,18 +344,9 @@ fn the_driver_does_not_overwrite_an_addons_takeover() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// **A hovered plate wears no black box** — the defect the widget port shipped (director report,
|
||||
/// 2026-09-10: "they look totally fucked when I hover or click them"), and the two halves that
|
||||
/// keep it fixed.
|
||||
///
|
||||
/// `Nameplate-Glow.blp` is DXT1 with **no alpha channel** and 80.5% pure-black texels. The
|
||||
/// reference blends it `ADD` (`push 3` @`0x7cb36a`); blitted with straight `BLEND` it is an opaque
|
||||
/// rectangle over the entire plate — which is exactly what the ctor's default mode produced here.
|
||||
/// 0184 is the director's call that benilla paints **no rim at all**, so the region has to be ADD
|
||||
/// *and* emit nothing, while staying shown and textured for the addons that read it.
|
||||
///
|
||||
/// The border assert is not decoration: without it the test would pass on an extract that produced
|
||||
/// no quads whatsoever.
|
||||
/// The glow art has no alpha and is mostly black, so the reference blends it ADD (`0x7cb36a`); as
|
||||
/// BLEND it is an opaque box. Deviation: we paint no rim, which reads as hard edges in our
|
||||
/// pipeline, but the region stays shown, ADD and textured. The border check fails an empty extract.
|
||||
#[test]
|
||||
fn a_hovered_plate_emits_no_glow_quad() {
|
||||
use crate::script::QuadContent;
|
||||
@@ -403,7 +356,6 @@ fn a_hovered_plate_emits_no_glow_quad() {
|
||||
p.lit = true;
|
||||
drive(&mut s, &[p]);
|
||||
|
||||
// The model tells the truth: shown, textured, and the reference's own blend mode.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local _, glow = WorldFrame:GetChildren():GetRegions()
|
||||
@@ -431,23 +383,9 @@ fn a_hovered_plate_emits_no_glow_quad() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The plate's name and level seat off the UI pixel grid** (decision 2172).
|
||||
///
|
||||
/// The plate is a WorldFrame overlay: the driver snaps its rect to the DEVICE pixel grid
|
||||
/// (`vplates::device_snap` — half a logical pixel at 2×) because it slides continuously over the
|
||||
/// world, and everything drawn inside it has to be rigid to that. The renderer's UI seat snap
|
||||
/// quantizes a text block's top on the coarser LOGICAL grid, so with both laws in force the name
|
||||
/// and the level pop a whole pixel every second step the border takes.
|
||||
///
|
||||
/// The painter this port replaced never snapped them — it measured into degenerate rects, which
|
||||
/// the renderer's carve-out skips — so this flag is what carries that seating law across decision
|
||||
/// 2148's move into the frame system. It rides the extract, one field per Text quad, because the
|
||||
/// renderer is the only thing that can act on it.
|
||||
///
|
||||
/// It is answered from the OWNER, which is why the third block here matters: pfUI and
|
||||
/// ShaguTweaks blank the stock regions and hang their own FontStrings on the plate, and a rule
|
||||
/// written on our six regions would have left an addon's strings jittering inside a plate whose
|
||||
/// own text had stopped.
|
||||
/// Text on a plate skips the UI grid snap: the plate's rect snaps to the finer device grid as it
|
||||
/// slides, and a text top snapped to the logical grid would jump a pixel against it. The flag is
|
||||
/// the owning frame's, so an addon's own string on the plate gets it too.
|
||||
#[test]
|
||||
fn the_plates_texts_carry_the_world_seat_and_its_textures_do_not() {
|
||||
use crate::script::QuadContent;
|
||||
@@ -472,8 +410,7 @@ fn the_plates_texts_carry_the_world_seat_and_its_textures_do_not() {
|
||||
"the plate draws exactly its name and level, both seated off the UI grid"
|
||||
);
|
||||
|
||||
// A FontString on an ordinary frame is interface, and interface is ON the grid — the flag has
|
||||
// to be the plate's claim, not something every string picked up.
|
||||
// A FontString on an ordinary frame stays on the grid.
|
||||
s.run(
|
||||
r#"local f = CreateFrame("Frame", "Chrome", UIParent) f:SetAllPoints()
|
||||
local t = f:CreateFontString("ChromeText") t:SetAllPoints() t:SetText("Chrome")"#,
|
||||
@@ -485,8 +422,7 @@ fn the_plates_texts_carry_the_world_seat_and_its_textures_do_not() {
|
||||
QuadContent::Text { text: Some(t), world_seat: false, .. } if t == "Chrome"
|
||||
)));
|
||||
|
||||
// An addon's own string on the plate — pfUI's whole vanilla branch — is drawn inside the same
|
||||
// sliding rect, so it gets the same seat.
|
||||
// An addon's own string on the plate, as pfUI draws its text, gets the same seat.
|
||||
s.run(
|
||||
r#"local plate = WorldFrame:GetChildren()
|
||||
local t = plate:CreateFontString() t:SetAllPoints() t:SetText("pfName")"#,
|
||||
@@ -502,11 +438,7 @@ fn the_plates_texts_carry_the_world_seat_and_its_textures_do_not() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The suppression is **the reference's own glow art on an ADD region**, and nothing wider: an
|
||||
/// addon that re-textures the region gets its art painted like any other texture.
|
||||
///
|
||||
/// pfUI's vanilla branch does exactly this — it blanks the six regions and paints its own — so a
|
||||
/// blanket "plate glow regions never draw" would silently eat an addon's work.
|
||||
/// Only the stock glow art on an ADD region goes unpainted; an addon's own glow texture paints.
|
||||
#[test]
|
||||
fn an_addon_that_retextures_the_glow_gets_its_art_painted() {
|
||||
use crate::script::QuadContent;
|
||||
@@ -531,10 +463,8 @@ fn an_addon_that_retextures_the_glow_gets_its_art_painted() {
|
||||
.any(|p| p.contains("pfUI")));
|
||||
}
|
||||
|
||||
/// pfUI reads `glow:IsShown()` as the MOUSEOVER signal (`:601`, `:880`). benilla does not paint the
|
||||
/// additive rim (0184 — it read as hard edge lines on our pipeline, and the director pinned a bar
|
||||
/// brighten instead), but the region is structurally present and really shown, because the
|
||||
/// deviation is about pixels and this is about data.
|
||||
/// pfUI reads `glow:IsShown()` as the mouseover signal (`:601`, `:880`), so the glow shows on hover
|
||||
/// though it paints nothing.
|
||||
#[test]
|
||||
fn the_glow_region_is_the_mouseover_signal() {
|
||||
let mut s = vm();
|
||||
@@ -554,9 +484,8 @@ fn the_glow_region_is_the_mouseover_signal() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The level number and the skull share one seat and are mutually exclusive — `skull` is how the
|
||||
/// driver says "world boss, or ten levels up". pfUI gates on `levelicon:IsShown()` and
|
||||
/// CustomNameplates on `Boss:IsVisible()`, so both flags have to move.
|
||||
/// The level and the skull (a world boss, or ten levels up) share one seat; pfUI reads
|
||||
/// `levelicon:IsShown()` and CustomNameplates `Boss:IsVisible()`.
|
||||
#[test]
|
||||
fn the_skull_replaces_the_level_number() {
|
||||
let mut s = vm();
|
||||
@@ -574,9 +503,7 @@ fn the_skull_replaces_the_level_number() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// **A unit whose level has not arrived is not a world boss.** `level: None` with no skull leaves
|
||||
/// the seat EMPTY — the old painter's behaviour, which the widget port inverted by making one
|
||||
/// `Option` carry both facts (audit, 2026-09-10).
|
||||
/// A level not yet known, with no skull, leaves the seat empty.
|
||||
#[test]
|
||||
fn a_unit_with_no_level_yet_wears_no_skull() {
|
||||
let mut s = vm();
|
||||
@@ -593,15 +520,11 @@ fn a_unit_with_no_level_yet_wears_no_skull() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// **A plate the pool grows on any frame but the first is laid out too.** The lay-out used to ride
|
||||
/// a per-frame "did the window move?" flag, so a unit that walked into range later got a plate with
|
||||
/// no size and no anchors: invisible, permanently, and the number of plates on screen was capped at
|
||||
/// however many were up when V was first pressed (audit, 2026-09-10).
|
||||
#[test]
|
||||
fn a_plate_created_after_the_first_frame_is_laid_out() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
// A second unit arrives on a LATER frame, with no free slot and no geometry change.
|
||||
// A second unit on a later frame, with no free slot and no geometry change.
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
|
||||
@@ -610,7 +533,7 @@ fn a_plate_created_after_the_first_frame_is_laid_out() {
|
||||
.eval("local _, b = WorldFrame:GetChildren() return b:GetWidth(), b:GetHeight()")
|
||||
.unwrap();
|
||||
assert_eq!((w, h), (128.0, 32.0), "the second plate has the plate size");
|
||||
// …and its regions are anchored to it, which is what `GetLeft` answering at all proves.
|
||||
// Its regions are anchored to it, or `GetLeft` would not answer.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local _, b = WorldFrame:GetChildren()
|
||||
@@ -620,10 +543,8 @@ fn a_plate_created_after_the_first_frame_is_laid_out() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// An addon's own frame parented to `WorldFrame` shares the list with the plates —
|
||||
/// `_NameplatesFrame` and `pfUICombatScreen` both are — which is why the identification test
|
||||
/// (`0x7a1390`'s always-nil `GetName`) is load-bearing and why the count-grew heuristic re-scans.
|
||||
/// It must not be mistaken for one.
|
||||
/// An addon's frame under `WorldFrame` (`pfUICombatScreen`, `_NameplatesFrame`) shares the list
|
||||
/// with the plates, whose `GetName` is always nil (`0x7a1390`).
|
||||
#[test]
|
||||
fn an_addon_frame_under_the_worldframe_is_not_a_plate() {
|
||||
let mut s = vm();
|
||||
@@ -653,11 +574,9 @@ fn an_addon_frame_under_the_worldframe_is_not_a_plate() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The plate takes the mouse, and hands its unit back.** It is a `Button` and born mouse-enabled
|
||||
/// (`CSimpleButton`'s ctor writes `[+0xcc] = 0x4`), so hovering it makes it the mouse focus — which
|
||||
/// on this engine means the UI pointer pass owns the cursor and the world pick stands down. What
|
||||
/// keeps the mouseover alive is the seam: the widget layer answers WHICH unit's plate the pointer
|
||||
/// is inside, exactly as the reference's OnEnter (`0x7cb850`) publishes `[0xb4e2c8]`.
|
||||
/// A plate is a mouse-enabled `Button` (`CSimpleButton`'s constructor writes `[+0xcc] = 0x4`), so
|
||||
/// hovering it takes the mouse focus from the world; the widget layer then names the plate's unit,
|
||||
/// as the reference's OnEnter (`0x7cb850`) publishes `[0xb4e2c8]`.
|
||||
#[test]
|
||||
fn hovering_a_plate_names_its_unit() {
|
||||
let mut s = vm();
|
||||
@@ -666,19 +585,16 @@ fn hovering_a_plate_names_its_unit() {
|
||||
drive(&mut s, &[wolf]);
|
||||
|
||||
assert_eq!(s.hovered_nameplate(), None, "nothing hovered yet");
|
||||
// The plate's TOP-CENTRE is (500, 400) and it hangs 32 units below, so this is its middle.
|
||||
// The plate hangs 32 units below its top centre (500, 400), so this is its middle.
|
||||
s.mouse_move(500.0, 384.0);
|
||||
assert_eq!(s.hovered_nameplate(), Some(key));
|
||||
// Off the plate: the WorldFrame takes the focus again, and no plate is named.
|
||||
// Off the plate the WorldFrame takes the focus again.
|
||||
s.mouse_move(50.0, 50.0);
|
||||
assert_eq!(s.hovered_nameplate(), None);
|
||||
}
|
||||
|
||||
/// A completed click on a plate reaches the app — the reference's click slot (`0x7cb910`), whose
|
||||
/// tail is the same `SetSelection` a click on the body runs.
|
||||
///
|
||||
/// **Mouse-UP only**: the plate registers `LeftButtonUp | RightButtonUp` (`RegisterForClicks(0x500)`
|
||||
/// at `0x7cb637`), so a press alone selects nothing.
|
||||
/// The reference's click slot (`0x7cb910`) ends in the same `SetSelection` as a click on the body,
|
||||
/// and fires on the up edge only: `RegisterForClicks(0x500)` at `0x7cb637`.
|
||||
#[test]
|
||||
fn a_completed_click_on_a_plate_reaches_the_app() {
|
||||
let mut s = vm();
|
||||
@@ -703,14 +619,9 @@ fn a_completed_click_on_a_plate_reaches_the_app() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The right button too** — `RegisterForClicks(0x500)` is LeftButtonUp | **RightButtonUp**
|
||||
/// (`0x7cb637` → `[this+0x330]`), and the reference's slot forks on it: mask 1 → `0x4925d0`
|
||||
/// select, mask 4 → `0x492820` select **and interact** (decision 2233).
|
||||
///
|
||||
/// The regression this pins: a plate is created as a plain `Button`, whose default registered set
|
||||
/// is `{"LeftButtonUp"}` alone, so the release was refused before the click funnel — and with 2233
|
||||
/// taking the camera off a press that lands on a plate, that left right-clicking a nameplate doing
|
||||
/// nothing whatsoever.
|
||||
/// `0x500` includes RightButtonUp (`0x7cb637` writes `[this+0x330]`), where a plain `Button`
|
||||
/// registers LeftButtonUp alone; the slot forks on the mask: 1 selects (`0x4925d0`), 4 selects and
|
||||
/// interacts (`0x492820`).
|
||||
#[test]
|
||||
fn a_physical_right_click_on_a_plate_reaches_the_app() {
|
||||
let mut s = vm();
|
||||
@@ -731,8 +642,6 @@ fn a_physical_right_click_on_a_plate_reaches_the_app() {
|
||||
assert_eq!(clicks[0].button, "RightButton");
|
||||
}
|
||||
|
||||
/// A button the plate never registered fires nothing — the mask is exactly `0x500`, not "any
|
||||
/// button".
|
||||
#[test]
|
||||
fn a_middle_click_on_a_plate_fires_nothing() {
|
||||
let mut s = vm();
|
||||
@@ -746,10 +655,8 @@ fn a_middle_click_on_a_plate_fires_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// pfUI's click-through calls `plate:Click("LeftButton")` (`nameplates.lua:1274`), and
|
||||
/// CustomNameplates and `_Nameplates` do the same. A scripted click has to select the unit like a
|
||||
/// physical one — in the reference both go through the button's one click slot, and here they go
|
||||
/// through the one funnel that slot's twin lives on.
|
||||
/// pfUI's click-through (`nameplates.lua:1274`) calls `plate:Click`; in the reference a scripted
|
||||
/// and a physical click go through the button's one click slot.
|
||||
#[test]
|
||||
fn a_scripted_click_selects_too() {
|
||||
let mut s = vm();
|
||||
@@ -764,15 +671,9 @@ fn a_scripted_click_selects_too() {
|
||||
assert_eq!(clicks[0].button, "RightButton");
|
||||
}
|
||||
|
||||
/// **The mouselook toggle** (`0x60f830`): entering freelook hands the mouse back on every plate,
|
||||
/// leaving takes it again — the reference walks its own intrusive plate list doing exactly this,
|
||||
/// from `0x483e80` (enter) and `0x483e70` (leave).
|
||||
///
|
||||
/// **What it is for is NOT "a drag that begins over a plate"** — that gesture never reaches
|
||||
/// mouselook at all, because `0x7662c0` hands the mouse-down to the plate and stops the bus walk
|
||||
/// before any binding runs (decision 2233, which reversed the inference this doc used to carry).
|
||||
/// It is for a turn that started on the **world** and then dragged the pointer across a plate: the
|
||||
/// plates must not take a pointer that is hidden and locked to the camera.
|
||||
/// Entering mouselook turns the mouse off on every plate and leaving turns it back on (`0x60f830`,
|
||||
/// from `0x483e80` and `0x483e70`), for a turn begun on the world that drags across a plate; a
|
||||
/// press on a plate never reaches mouselook (`0x7662c0`).
|
||||
#[test]
|
||||
fn freelook_hands_the_mouse_back() {
|
||||
let mut s = vm();
|
||||
@@ -796,14 +697,9 @@ fn freelook_hands_the_mouse_back() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The plate's own `+0x3c` veto** (`0x7cba30`) — a DIFFERENT mechanism from the freelook toggle
|
||||
/// above, and the reason it is not folded into the same flag.
|
||||
///
|
||||
/// While a ground-targeted spell is armed the plate refuses the hit test *before* testing its rect
|
||||
/// (`0x6e48a0() && 0x6e6320() && !0x6e6180()` → `xor eax,eax; ret 4`, never calling the base), so
|
||||
/// the point falls through to the `WorldFrame` at (strata 0, level 0) behind it and the reticle can
|
||||
/// be placed through a plate. Crucially it never touches `[+0xcc]`: `IsMouseEnabled()` still
|
||||
/// answers **true** throughout, which is exactly what `0x60f830` does not do.
|
||||
/// The plate's `+0x3c` hit test (`0x7cba30`) refuses the point while a ground-targeted spell is
|
||||
/// armed (`0x6e48a0() && 0x6e6320() && !0x6e6180()`), so it falls through to the `WorldFrame`.
|
||||
/// It never touches `[+0xcc]`: `IsMouseEnabled` stays true, unlike under mouselook (`0x60f830`).
|
||||
#[test]
|
||||
fn a_ground_target_veto_refuses_the_hit_without_disabling_the_mouse() {
|
||||
let mut s = vm();
|
||||
@@ -818,15 +714,13 @@ fn a_ground_target_veto_refuses_the_hit_without_disabling_the_mouse() {
|
||||
None,
|
||||
"the reticle must be placeable through a plate"
|
||||
);
|
||||
// …and the press falls through with it, which is what lets the WorldFrame win the gesture and
|
||||
// the ground cast commit where the player clicked.
|
||||
// The press falls through too, so the WorldFrame takes the ground cast.
|
||||
s.mouse_button(500.0, 384.1, "LeftButton", true);
|
||||
s.mouse_button(500.0, 384.1, "LeftButton", false);
|
||||
assert!(
|
||||
s.take_nameplate_clicks().is_empty(),
|
||||
"a vetoed plate takes no click either"
|
||||
);
|
||||
// The bit the veto must NOT have touched — an addon asking is told the truth.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:IsMouseEnabled()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
//! **Shape C, closed as a CLASS**: every shape-C numeric position must take a `nil` — or a
|
||||
//! table, or a string — as **0.0** and complete, because the reference reads it with a bare
|
||||
//! `lua_tonumber 0x6f3620` and no `lua_isnumber` gate at all.
|
||||
//!
|
||||
//! This is one test and not six because which shape a given argument takes is per binding, not a
|
||||
//! global law: the reference's 408 widget-registrar positions (110 gated, 64 ungated) are a table a
|
||||
//! re-implementation is checked against, not an instinct. Our half of that table had drifted: 1973
|
||||
//! closed `SetTextColor` and left every sibling strict, so mlua's converter went on being the gate
|
||||
//! for five more years' worth of call sites.
|
||||
//!
|
||||
//! The live symptom that forced the audit: stock `QuestLogFrame.lua:337` does
|
||||
//! `QuestLogSkillHighlight:SetVertexColor(titleButton.r, titleButton.g, titleButton.b)`, and those
|
||||
//! three fields are only ever assigned in `QuestLog_Update` — so **any** path that selects a
|
||||
//! quest-log entry before the window has painted hands `SetVertexColor` three nils. Questie's
|
||||
//! `syncQuestWatch` (`QuestieTracker.lua:1160`) is exactly such a path: it calls
|
||||
//! `QuestLog_SetSelection` first. On the reference that is a no-op that stores black; for us it
|
||||
//! raised, and because the raise happened inside Questie's own event-queue drain — which nils an
|
||||
//! entry only *after* its handler returns — the queue jammed at that entry permanently. Every
|
||||
//! later entry (`SYNCLOG`, `DRAWNOTES`, `TRACKER`) was stranded for the rest of the session, which
|
||||
//! is why the minimap button's right-click could turn the quest icons OFF (a direct call) but
|
||||
//! never back ON (a `DRAWNOTES` enqueue).
|
||||
//!
|
||||
//! **Shape B is not shape C and is not tested here.** The alpha slot of every colour setter is B —
|
||||
//! a pre-staged default, `1.0f` at `0x778220` — so its absence is a *default*, not a zero, and
|
||||
//! `SetVertexColor`'s own B-slot carries a separate, deliberately-open divergence (1782).
|
||||
//! Shape C: a numeric position the reference reads with a bare `lua_tonumber` (`0x6f3620`) and no
|
||||
//! `lua_isnumber` gate, so nil, a table or a string is 0.0 and the call completes. Shape B, not
|
||||
//! tested here, is a pre-staged default, such as every colour setter's alpha (1.0 at `0x778220`).
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Each row: the Lua that must not raise, and what a reader must say afterwards.
|
||||
#[test]
|
||||
fn every_shape_c_colour_position_takes_nil_as_zero_and_never_raises() {
|
||||
let s = script();
|
||||
@@ -41,7 +18,7 @@ fn every_shape_c_colour_position_takes_nil_as_zero_and_never_raises() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `Texture:SetVertexColor 0x79abd0` — `2=C 3=C 4=C`. The QuestLogFrame.lua:337 shape verbatim.
|
||||
// `Texture:SetVertexColor 0x79abd0` (`2=C 3=C 4=C`), as stock `QuestLogFrame.lua:337` calls it.
|
||||
s.run("T:SetVertexColor(nil, nil, nil)")
|
||||
.expect("SetVertexColor(nil,nil,nil) is three bare lua_tonumbers, not a raise");
|
||||
s.run(
|
||||
@@ -51,11 +28,11 @@ fn every_shape_c_colour_position_takes_nil_as_zero_and_never_raises() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A table and a string are the same 0.0 — `lua_tonumber` tests nothing.
|
||||
// A table and a string are 0.0 too: `lua_tonumber` tests nothing.
|
||||
s.run("T:SetVertexColor({}, \"abc\", true)")
|
||||
.expect("no tag is rejected at a shape-C position");
|
||||
|
||||
// `Texture:SetTexCoord 0x79beb0` — every coordinate C; only the ARITY raises.
|
||||
// `Texture:SetTexCoord 0x79beb0`: every coordinate C; only the arity raises.
|
||||
s.run("T:SetTexCoord(nil, nil, nil, nil)")
|
||||
.expect("four nil coordinates are four zeroes");
|
||||
s.run("T:SetTexCoord(0, 1, 0, 1, 0, 1, 0, 1)")
|
||||
@@ -76,18 +53,17 @@ fn every_shape_c_colour_position_takes_nil_as_zero_and_never_raises() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `ColorSelect:SetColorRGB 0x78eae0` — C on all three, and it fires OnColorSelect either way.
|
||||
// `ColorSelect:SetColorRGB 0x78eae0`: C on all three; it fires OnColorSelect either way.
|
||||
s.run("CS:SetColorRGB(nil, nil, nil)")
|
||||
.expect("ColorSelect:SetColorRGB is shape C on r/g/b");
|
||||
|
||||
// `Texture:SetGradient 0x79ae30` / `SetGradientAlpha 0x79b180` — already C before this pass;
|
||||
// pinned so they cannot regress into the family's old strictness.
|
||||
// `Texture:SetGradient 0x79ae30` and `SetGradientAlpha 0x79b180`.
|
||||
s.run("T:SetGradient(\"HORIZONTAL\", nil, nil, nil, nil, nil, nil)")
|
||||
.expect("SetGradient's six stops are all C");
|
||||
s.run("T:SetGradientAlpha(\"VERTICAL\", nil, nil, nil, nil, nil, nil, nil, nil)")
|
||||
.expect("SetGradientAlpha's eight are C/B");
|
||||
|
||||
// `FontString:SetTextColor 0x79d9c0` — closed by 1973; pinned here so the class stays closed.
|
||||
// `FontString:SetTextColor 0x79d9c0`.
|
||||
s.run("FS:SetTextColor(nil, nil, nil)")
|
||||
.expect("the sibling 1973 already fixed");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,5 @@
|
||||
//! **The Region map is ONE function per name, reached by frames and regions alike** — the
|
||||
//! [`crate::script::region_map`] law, and the regression guard for bug B267.
|
||||
//!
|
||||
//! Its header carries the byte evidence (`Frame ∩ Region = ∅`, parsed out of the two `.data`
|
||||
//! method tables). What is pinned here is the two things a reader of the code could not otherwise
|
||||
//! check: that the sharing is real *by identity*, and that it stops exactly at the 19.
|
||||
//!
|
||||
//! The reported symptom this file exists for: Quiver's `Api/Index.wow.lua` opens
|
||||
//! `_Height = WorldFrame.GetHeight`, applies it to a Texture and a FontString, and benilla raised
|
||||
//! `stale or invalid frame handle` — inside the addon's `VARIABLES_LOADED` handler, four lines
|
||||
//! before it published `Quiver.CastPetAction`. The addon's own field was nil at macro time because
|
||||
//! the handler never finished, which is what got filed.
|
||||
//! The Region map ([`crate::script::region_map`]): one function per name for frames and regions
|
||||
//! alike, shared by identity and stopping at the 19.
|
||||
|
||||
use crate::script::UiScript;
|
||||
|
||||
@@ -34,12 +24,7 @@ fn vm() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// Every one of the 19 is the **same Lua value** on a Frame, a Texture and a FontString.
|
||||
///
|
||||
/// Not a stand-in for the behaviour tests below: identity is what makes the pulled-off-method
|
||||
/// idiom work *for a method the test author never thought of*. In the client it holds because
|
||||
/// Frame's table does not carry any of the 19 and its lookup tail-calls Region's; here it holds
|
||||
/// because one function is written into every table that chain reaches.
|
||||
/// In the client, Frame's table carries none of the 19 and its lookup tail-calls Region's.
|
||||
#[test]
|
||||
fn the_region_map_is_one_function_on_every_widget() {
|
||||
let s = vm();
|
||||
@@ -56,12 +41,10 @@ fn the_region_map_is_one_function_on_every_widget() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The bug, reduced to its four lines.** A method pulled off a frame and applied to a region.
|
||||
#[test]
|
||||
fn a_method_pulled_off_a_frame_works_on_a_texture_and_a_fontstring() {
|
||||
let s = vm();
|
||||
// Quiver's own shape: `Api._Height = WorldFrame.GetHeight`, applied to `r.Icon` (a Texture)
|
||||
// and `r.Label` (a FontString), from a table built at file scope.
|
||||
// Quiver's shape: `Api._Height = WorldFrame.GetHeight`, applied to a Texture and a FontString.
|
||||
let (th, sh): (f32, f32) = s
|
||||
.eval(
|
||||
r#"
|
||||
@@ -82,13 +65,10 @@ fn a_method_pulled_off_a_frame_works_on_a_texture_and_a_fontstring() {
|
||||
.unwrap();
|
||||
assert_eq!((tw, sw), (64.0, 50.0));
|
||||
|
||||
// …and the setter half, which is the same split and would have been the next wall.
|
||||
s.run("local _SetW = Host.SetWidth _SetW(Tex, 99)").unwrap();
|
||||
assert_eq!(s.eval::<f32>("return Tex:GetWidth()").unwrap(), 99.0);
|
||||
}
|
||||
|
||||
/// The other direction — a method pulled off a **region** and applied to a **frame**. Same one
|
||||
/// function, so this cannot be made to work by special-casing the reported direction.
|
||||
#[test]
|
||||
fn a_method_pulled_off_a_texture_works_on_a_frame() {
|
||||
let s = vm();
|
||||
@@ -102,9 +82,7 @@ fn a_method_pulled_off_a_texture_works_on_a_frame() {
|
||||
assert_eq!(kind, "Frame");
|
||||
}
|
||||
|
||||
/// Each receiver keeps its OWN behaviour behind the shared name — the per-kind arms are not
|
||||
/// collapsed into one. `GetObjectType` is the cleanest witness (three different answers), and
|
||||
/// `GetParent` is the one whose two arms genuinely differ (a region's owner is never nil).
|
||||
/// The shared function keeps a per-kind arm; a region's `GetParent` is its owner, never nil.
|
||||
#[test]
|
||||
fn one_name_still_dispatches_per_kind() {
|
||||
let s = vm();
|
||||
@@ -115,14 +93,12 @@ fn one_name_still_dispatches_per_kind() {
|
||||
(f.as_str(), t.as_str(), g.as_str()),
|
||||
("Frame", "Texture", "FontString")
|
||||
);
|
||||
// The region arm answers its OWNER; the frame arm answers nil for a parentless top frame.
|
||||
let owner: String = s.eval("return Tex:GetParent():GetName()").unwrap();
|
||||
assert_eq!(owner, "Host");
|
||||
assert!(s.eval::<bool>("return Host:GetParent() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `GetNumPoints` — on the Region map, so **every** widget answers it. The frame arm did not
|
||||
/// exist until the map was collapsed to one implementation; the region arm shipped alone.
|
||||
/// `GetNumPoints` is on the Region map, so every widget answers it.
|
||||
#[test]
|
||||
fn get_num_points_answers_on_a_frame_too() {
|
||||
let s = vm();
|
||||
@@ -135,11 +111,8 @@ fn get_num_points_answers_on_a_frame_too() {
|
||||
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// **The negative case, and the control that keeps it honest.** These six look like they belong to
|
||||
/// the Region map and do not: Frame, Texture and FontString each register their *own* at different
|
||||
/// addresses — Texture `SetAlpha 0x79b580` vs FontString `0x79cb70`, and Frame's own `0x774e90`
|
||||
/// twin. So `WorldFrame.Show(someTexture)` fails on the real client, and hoisting them here to make
|
||||
/// more addon code work would be the superset decision 1189 had to take back out.
|
||||
/// Frame, Texture and FontString each register their own copy of these six (Texture `SetAlpha`
|
||||
/// `0x79b580`, FontString's `0x79cb70`, Frame's `0x774e90`), so `WorldFrame.Show(tex)` fails.
|
||||
#[test]
|
||||
fn the_six_look_alikes_are_not_shared() {
|
||||
let s = vm();
|
||||
@@ -159,8 +132,7 @@ fn the_six_look_alikes_are_not_shared() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A receiver that is not a widget at all raises, and says *why* — the bridge resolves the
|
||||
/// receiver before either arm runs, so this error must not be flattened into a generic one.
|
||||
/// The bridge resolves the receiver before either arm runs, and its error names the reason.
|
||||
#[test]
|
||||
fn a_non_widget_receiver_raises_and_names_the_reason() {
|
||||
let s = vm();
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! Regions join the anchor layout (decision 0068).
|
||||
//!
|
||||
//! The smeared-merchant root cause: region `<Size>`/`<Anchors>` used to be dropped — a region either
|
||||
//! filled its owner or (with a size) drew centered, and its anchors were ignored. Regions now resolve
|
||||
//! through the same leaf math as frames, off their own anchors and their own span; an edge nothing
|
||||
//! pins does NOT fall back to the owner's (decision 1664 retired that — the owner supplies scale and
|
||||
//! nothing else), because the span is content-derived and a pinned edge plus a span is a rect.
|
||||
//! Regions in the anchor layout: they resolve through the frames' leaf math, off their own anchors
|
||||
//! and content-derived span; the owner supplies scale, never a fallback edge.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Rect;
|
||||
@@ -56,11 +51,8 @@ fn region_texture_anchored_topleft_resolves_exact_rect() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A FontString's implicit extent is its measured TEXT, **floored at one FrameXML unit**
|
||||
/// (`CSimpleFontString::GetWidth 0x772930` / `GetHeight 0x772a60` — decision 1664). So a
|
||||
/// single-anchored FontString whose measure has not landed is a 1×1 box seated on its anchor,
|
||||
/// not a collapse onto the pinned edge and not the owner's rect: the floor is what makes such a
|
||||
/// FontString **always resolve**, and it is why the resolver needs no owner-edge fallback at all.
|
||||
/// A FontString's extent is its measured text floored at one unit (`GetWidth 0x772930`,
|
||||
/// `GetHeight 0x772a60`), so an unmeasured one is a 1x1 box on its anchor.
|
||||
#[test]
|
||||
fn region_fontstring_span_floors_at_one_unit_until_measured() {
|
||||
let mut s = script();
|
||||
@@ -78,8 +70,7 @@ fn region_fontstring_span_floors_at_one_unit_until_measured() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// Pending measure: both spans floor at one unit. x runs right from the pinned left (5→6);
|
||||
// a LEFT point pins the y-CENTER only (25), so y is the centre ± half a unit.
|
||||
// Unmeasured: x runs 5..6 from the pinned left; LEFT pins the y centre (25), so y is ±0.5.
|
||||
assert_eq!(
|
||||
region_text_rect(&s, "Name"),
|
||||
Rect::new(24.5, 5.0, 25.5, 6.0)
|
||||
@@ -98,16 +89,9 @@ fn region_fontstring_span_floors_at_one_unit_until_measured() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ExhaustionLevelFillBar`'s exact shape, both ways: authored width **0**, one TOPLEFT anchor,
|
||||
/// and a `<Color>`.
|
||||
///
|
||||
/// The colour form installs a real 8×8 texture before any resolve (`0x7700a9` → `0x770360` →
|
||||
/// `0x44a900`), so `CSimpleTexture::GetWidth 0x770720` answers **8** for the authored zero and
|
||||
/// `combineEdge`'s first leg fires: RIGHT = LEFT + 8, and `assemble 0x767a20` returns 1. Strip the
|
||||
/// colour and there is no `CGxTex*` at all — the getter answers `0.0`, both `combineEdge` legs
|
||||
/// fail their `span != 0.0` test, and the rect never resolves. Our owner-edge fallback used to
|
||||
/// turn that second case into the owner's **full width** (1348's 1024-point white band); it is
|
||||
/// gone, so the two cases now differ by 8 points and a rect, exactly as they do on the reference.
|
||||
/// `ExhaustionLevelFillBar`'s shape, width 0 and one TOPLEFT anchor: its `<Color>` installs an 8x8
|
||||
/// texture (`0x7700a9` → `0x770360` → `0x44a900`), so `GetWidth 0x770720` answers 8; with no art
|
||||
/// it answers 0.0, and `assemble 0x767a20` leaves the rect unresolved.
|
||||
#[test]
|
||||
fn a_zero_width_solid_spans_eight_units_and_its_artless_twin_gets_no_rect() {
|
||||
let mut s = script();
|
||||
@@ -142,11 +126,8 @@ fn a_zero_width_solid_spans_eight_units_and_its_artless_twin_gets_no_rect() {
|
||||
|
||||
#[test]
|
||||
fn templateless_lua_region_without_anchors_never_draws() {
|
||||
// Decision 1310 (`CreateTexture 0x773a20`): the creation-path implicit
|
||||
// anchor fires from Lua CreateTexture only on a template-registry hit — a templateless region
|
||||
// gets NOTHING, stays rect-less (the resolver has no zero-anchor fallback), and never renders,
|
||||
// its explicit size notwithstanding. This replaced the old draws-centered-at-its-size
|
||||
// fallback, which was refuted at the bytes (it was B180's squashed stack-split dialog).
|
||||
// `CreateTexture 0x773a20` anchors a new texture only on a template hit, so a templateless one
|
||||
// has no anchor, no rect and no draw, whatever its size.
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -193,11 +174,8 @@ fn region_set_all_points_fills_owner() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A region anchored to a **sibling region by name** resolves against the sibling's rect — the
|
||||
/// merchant label-plate shape (`plate LEFT → $parentSlot RIGHT −9`), which the old owner-fallback
|
||||
/// mis-anchored to the row's right edge (the jutting-plates bug the director's A/B caught). The
|
||||
/// plate is declared BEFORE the slot so only the resolve fixpoint (not declaration order) can
|
||||
/// order the chain; the slot itself is region-anchored to the row, one deeper than owner-direct.
|
||||
/// A region anchored by name to a sibling declared after it: `MerchantItemTemplate`'s label plate
|
||||
/// (`LEFT` to `$parentSlotTexture`'s `RIGHT`, -9, -18), ordered by the resolve alone.
|
||||
#[test]
|
||||
fn region_anchors_to_sibling_region_by_name() {
|
||||
let mut s = script();
|
||||
@@ -232,7 +210,7 @@ fn region_anchors_to_sibling_region_by_name() {
|
||||
.and_then(|q| q.rect)
|
||||
.unwrap_or_else(|| panic!("no rect for {name}"))
|
||||
};
|
||||
// Slot: row TOPLEFT (100..253, 100..144) → slot at (87, 157)-(151, 93)… y-up: top = 144+13 = 157.
|
||||
// The row's TOPLEFT (100, 144) plus (-13, 13): the slot spans 87..151 by 93..157.
|
||||
let slot = rect("RowSlot");
|
||||
assert_eq!(
|
||||
(slot.left, slot.top),
|
||||
@@ -240,24 +218,20 @@ fn region_anchors_to_sibling_region_by_name() {
|
||||
"slot at row TOPLEFT (-13,13)"
|
||||
);
|
||||
assert_eq!(slot.right, 151.0);
|
||||
// Plate LEFT anchors to the SLOT's RIGHT (151) − 9 = 142 — NOT the row's right edge (253),
|
||||
// which is where the old owner-fallback shoved it (253 − 9 + 128 = jutting past everything).
|
||||
// The plate's LEFT is the slot's RIGHT (151) - 9 = 142, not the row's right edge (253).
|
||||
let plate = rect("RowPlate");
|
||||
assert_eq!(plate.left, 142.0, "plate.LEFT = slot.RIGHT − 9");
|
||||
assert_eq!(
|
||||
plate.right, 270.0,
|
||||
"plate spans its 128 width from the slot edge"
|
||||
);
|
||||
// The LEFT single-point anchor centers the plate vertically on the target point (y-up):
|
||||
// slot center-y = (93+157)/2 = 125, −18 → 107; ±39.
|
||||
// A LEFT point centres the plate vertically: the slot's centre 125, -18 = 107, ±39.
|
||||
assert_eq!((plate.bottom, plate.top), (68.0, 146.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `SetPortraitTexture(region, unit)` (the live model-bake portrait) binds a Texture region to a unit
|
||||
/// token, carried out through [`QuadContent::Texture::portrait_unit`] with no BLP path/color of its
|
||||
/// own (the app supplies the off-screen bake). A later `SetTexture` makes it an ordinary texture again,
|
||||
/// dropping the binding.
|
||||
/// `SetPortraitTexture` binds a Texture to a unit token, carried on the quad as `portrait_unit`
|
||||
/// with no path or colour (the app supplies the bake); a later `SetTexture` drops the binding.
|
||||
#[test]
|
||||
fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() {
|
||||
let mut s = script();
|
||||
@@ -291,7 +265,6 @@ fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() {
|
||||
assert_eq!(color, None, "the model bake carries no vertex color");
|
||||
assert!(circular, "the frame-ring portrait is the round stencil");
|
||||
|
||||
// SetTexture reverts the region to an ordinary texture — the live-unit binding drops.
|
||||
s.run(r#" PFramePortrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") "#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
@@ -308,9 +281,8 @@ fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `BenillaSetBoothTexture(region, token)` — the paper doll's **square** booth binding (decision
|
||||
/// 0208 §5): the same `portrait_unit` carriage as `SetPortraitTexture`, but `circular` stays
|
||||
/// false (the model pane samples the body bake edge to edge, no frame ring to mask for).
|
||||
/// `BenillaSetBoothTexture`, the paper doll's square booth binding: `SetPortraitTexture`'s
|
||||
/// carriage with `circular` false, as the model pane has no ring to mask for.
|
||||
#[test]
|
||||
fn benilla_set_booth_texture_binds_square() {
|
||||
let mut s = script();
|
||||
@@ -344,13 +316,8 @@ fn benilla_set_booth_texture_binds_square() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The quest-log detail pane's shape (the 0109 look fix): a LONG region anchor chain — the real
|
||||
/// client's QuestLogFrame chains ~15 regions deep (title → objectives → obj1..10 → description →
|
||||
/// rewards) — with a frame (the reward item button) anchored to the chain's tail. The resolver
|
||||
/// must run to a true fixpoint: the old bounded rounds (2 frame × 3 region passes) left links past
|
||||
/// ~6 on silent owner-edge fallbacks and dropped the tail-anchored FRAME to the screen origin —
|
||||
/// decision 0088 §2's "button anchored to a chained region falls to the screen origin" finding,
|
||||
/// which forced every window into invented fixed offsets instead of ref-verbatim chains.
|
||||
/// The quest log detail pane's shape: a region anchor chain about 15 deep (title, objectives,
|
||||
/// description, rewards) with a frame anchored to its tail, which the resolve runs to a fixpoint.
|
||||
#[test]
|
||||
fn long_region_chain_resolves_and_a_frame_binds_to_its_tail() {
|
||||
let mut s = script();
|
||||
@@ -402,10 +369,7 @@ fn long_region_chain_resolves_and_a_frame_binds_to_its_tail() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Decision 0088 §2 pinned "a child frame shown at runtime does not draw its own `<Layers>`
|
||||
/// FontStrings" — the engine constraint that forced every window FLAT. Re-tested after the
|
||||
/// resolver fixpoint (decision 0112): a child frame created and SHOWN at runtime, carrying its own
|
||||
/// text + texture regions, must extract both quads at the child's resolved position.
|
||||
/// A child frame created and shown at runtime draws its own regions where it resolves.
|
||||
#[test]
|
||||
fn child_frame_layers_regions_render_after_the_fixpoint() {
|
||||
let mut s = script();
|
||||
@@ -449,11 +413,9 @@ fn child_frame_layers_regions_render_after_the_fixpoint() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetAlpha`/`GetAlpha` on a Texture/FontString — the region's *own* alpha, distinct from its
|
||||
/// owner frame's. A region draws at `ownAlpha × ownerFrame.alpha`: a single hop to the immediate
|
||||
/// owner (`SetAlpha 0x76a690` overwrite-cascades onto child *frames* and only invalidates child
|
||||
/// regions). The getter must return the region's value, never the frame's: the ref kit ramps a
|
||||
/// texture by reading it back (`CastingBarFlash:SetAlpha(GetAlpha() + step)`).
|
||||
/// A region draws at its own alpha times its owner frame's, one hop (`SetAlpha 0x76a690` cascades
|
||||
/// onto child frames, not regions), and `GetAlpha` answers its own: stock `CastingBarFrame.lua:133`
|
||||
/// ramps `CastingBarFlash` by reading it back.
|
||||
#[test]
|
||||
fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
let mut s = script();
|
||||
@@ -480,12 +442,10 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
};
|
||||
assert_eq!(quad_alpha(&s), 1.0);
|
||||
|
||||
// The region's own alpha alone.
|
||||
s.run("tex:SetAlpha(0.5)").unwrap();
|
||||
assert_eq!(s.eval::<f32>("return tex:GetAlpha()").unwrap(), 0.5);
|
||||
assert_eq!(quad_alpha(&s), 0.5);
|
||||
|
||||
// …times the owner frame's. The frame's SetAlpha does NOT overwrite the region's own value.
|
||||
s.run("f:SetAlpha(0.5)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return tex:GetAlpha()").unwrap(),
|
||||
@@ -494,7 +454,6 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
);
|
||||
assert_eq!(quad_alpha(&s), 0.25, "0.5 region × 0.5 frame");
|
||||
|
||||
// A hidden region draws nothing regardless of alpha.
|
||||
s.run("tex:SetAlpha(1); tex:Hide()").unwrap();
|
||||
assert!(
|
||||
!s.extract()
|
||||
@@ -504,14 +463,9 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The texture-colour composition law**: a region's own solid colour is a real *texel*
|
||||
/// (`SetTexture(r,g,b,a)` → `0x770360` generates an 8×8 block at `+0xcc`), its vertex colour is a
|
||||
/// *separate* slot (`SetVertexColor 0x77f750`, `+0xb8`), and the draw **multiplies** them per
|
||||
/// channel, alpha included — it does not replace.
|
||||
///
|
||||
/// This is the reference `SkillFrame` row trough, verbatim: declared `<Color 1,1,1,0.2>`, then
|
||||
/// `SetVertexColor(0, 0, 0.75, 0.5)`'d. It draws at alpha `0.2 × 0.5 = 0.1`. benilla stored ONE
|
||||
/// colour slot until this test, which made the second call replace the first and drew it at 0.5.
|
||||
/// A solid colour is a texel (`0x770360`, an 8x8 block at `+0xcc`) and the vertex colour a
|
||||
/// separate slot (`SetVertexColor 0x77f750`, `+0xb8`); the draw multiplies them, alpha included,
|
||||
/// as stock `SkillFrame`'s row trough needs (`SkillFrame.xml:35`, `SkillFrame.lua:158`).
|
||||
#[test]
|
||||
fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
let mut s = script();
|
||||
@@ -551,15 +505,14 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
Some([0.0, 0.0, 0.75, 0.1]),
|
||||
"texel x vertex, alpha included: 0.2 x 0.5 = 0.1, NOT 0.5"
|
||||
);
|
||||
// The API readback is the vertex slot itself, not the product — the two are distinct storage.
|
||||
// `GetVertexColor` reads the vertex slot, not the product.
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return trough:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0.75, 0.5)
|
||||
);
|
||||
|
||||
// Art and a solid colour share the `+0xcc` slot: setting a path releases the generated texel,
|
||||
// and what is left is the tint alone for the renderer to modulate the sample by.
|
||||
// Art and a solid colour share `+0xcc`: a path releases the texel and leaves the tint.
|
||||
s.run(r#"trough:SetTexture("Interface\\Bar.blp")"#).unwrap();
|
||||
let tinted = s
|
||||
.extract()
|
||||
@@ -580,14 +533,8 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetDesaturated` rides the extract, and only against real ART** (decision 1327).
|
||||
///
|
||||
/// The state was stored from the day the verb landed and read by nobody, which is why every
|
||||
/// greyed-out affordance in the UI was a brightness tint (B162's talent tree). The flag now travels
|
||||
/// on the quad, so this pins the two ends of that wire — set/clear — plus the one carve-out: a
|
||||
/// PATHLESS solid has its colour folded into the quad's tint and draws against a 1x1 white texel,
|
||||
/// so a shader that greys the texel would grey white and change nothing. Carrying the flag there
|
||||
/// would read as honoured while doing nothing at all, so extract drops it.
|
||||
/// The desaturate flag rides an art quad only: a pathless solid draws its colour as the quad's tint
|
||||
/// over a white texel, which greying leaves white, so extract drops the flag there.
|
||||
#[test]
|
||||
fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
|
||||
let mut s = script();
|
||||
@@ -628,17 +575,12 @@ fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
|
||||
"a pathless solid never carries it — greying a white texel is a no-op dressed as a feature"
|
||||
);
|
||||
|
||||
// And it clears — the reference's own `SetItemButtonDesaturated(button, nil)` restore.
|
||||
s.run("art:SetDesaturated(nil)").unwrap();
|
||||
assert!(!grey(&s, true), "clearing the flag restores full colour");
|
||||
}
|
||||
|
||||
/// Read a Texture region's desaturation state straight off the model, by name.
|
||||
///
|
||||
/// It has no getter in Lua on purpose — `IsDesaturated` (`0x79c2c0`) exists, but its return shape
|
||||
/// is unconfirmed, and inventing one to make a test convenient is how an unverified API
|
||||
/// gets shipped (decision 1327's own residual). The extract quad is the other way to see it, but a
|
||||
/// cleared texture emits no quad at all, which is exactly the case these tests need to observe.
|
||||
/// A Texture region's desaturation state, read off the model by name: `IsDesaturated`
|
||||
/// (`0x79c2c0`) is not built, and a cleared texture emits no quad to read it from.
|
||||
fn desaturated(s: &UiScript, name: &str) -> bool {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
@@ -647,15 +589,8 @@ fn desaturated(s: &UiScript, name: &str) -> bool {
|
||||
model.region_data.get(&h).is_some_and(|d| d.desaturated)
|
||||
}
|
||||
|
||||
/// **`SetTexture` clears the desaturation — except when the path does not actually change**
|
||||
/// (decision 1330).
|
||||
///
|
||||
/// `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture` writes it from a shader index the
|
||||
/// Lua binding always passes as slot 0 (permanently NULL). Storing a desaturate boolean *beside*
|
||||
/// the texture handle — which is what benilla did on 1327 — diverges on the single most common
|
||||
/// FrameXML shape there is: a repaint that re-sets the icon and expects the grey to follow the art.
|
||||
/// The same-path early-out (`0x770225`) is what makes the *idempotent* repaint keep its grey, and
|
||||
/// it is the half a plausible implementation drops.
|
||||
/// The desaturation is the shader slot `+0x128`, which `CSimpleTexture::SetTexture` rewrites with
|
||||
/// the binding's always-NULL slot 0, except that the same path returns early (`0x770225`).
|
||||
#[test]
|
||||
fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
|
||||
let s = script();
|
||||
@@ -670,34 +605,28 @@ fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
|
||||
.unwrap();
|
||||
let grey = |s: &UiScript| desaturated(s, "ClrIcon");
|
||||
|
||||
// The idempotent repaint: same file, so the client returns before it can clear anything.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")"#)
|
||||
.unwrap();
|
||||
assert!(grey(&s), "re-setting the SAME art keeps the grey");
|
||||
|
||||
// A different file reaches the write and zeroes the slot.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Fire_Fireball")"#)
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "a texture CHANGE clears the desaturation");
|
||||
|
||||
// The clear forms take the same leg (`test esi,esi` falls through to the write).
|
||||
// nil takes the same leg (`test esi,esi` falls through to the write).
|
||||
s.run("icon:SetDesaturated(1) icon:SetTexture(nil)")
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "SetTexture(nil) clears it too");
|
||||
|
||||
// The COLOUR form is a different function (`0x770360`) and is not one of the field's writers.
|
||||
// The colour form (`0x770360`) does not write the shader slot.
|
||||
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep") icon:SetDesaturated(1)"#)
|
||||
.unwrap();
|
||||
s.run("icon:SetTexture(1, 0, 0)").unwrap();
|
||||
assert!(grey(&s), "the colour form does not touch the shader slot");
|
||||
}
|
||||
|
||||
/// **`SetDesaturated`'s argument truth table has two arms that read backwards**
|
||||
/// (`0x6f1c10`'s jump table; decision 1330).
|
||||
///
|
||||
/// `0x6f1c10(L, 2, default=1)` takes its DEFAULT on `LUA_TNONE`, so a bare `SetDesaturated()` greys
|
||||
/// — the opposite of the `if flag then` an implementation writes without looking. And a number is
|
||||
/// truncated to an int, so `SetDesaturated(0)` clears where truthiness would have greyed.
|
||||
/// `SetDesaturated` reads its flag with `0x6f1c10(L, 2, default=1)`: no argument (`LUA_TNONE`)
|
||||
/// greys, and a number is truncated to an int, so 0 and 0.5 clear.
|
||||
#[test]
|
||||
fn set_desaturated_takes_the_clients_argument_truth_table() {
|
||||
let s = script();
|
||||
@@ -705,7 +634,6 @@ fn set_desaturated_takes_the_clients_argument_truth_table() {
|
||||
.unwrap();
|
||||
let grey = |s: &UiScript| desaturated(s, "ArgTex");
|
||||
|
||||
// NO ARGUMENT is ON — the LUA_TNONE default arm, not the nil arm.
|
||||
s.run("ArgTex:SetDesaturated()").unwrap();
|
||||
assert!(
|
||||
grey(&s),
|
||||
@@ -715,26 +643,20 @@ fn set_desaturated_takes_the_clients_argument_truth_table() {
|
||||
s.run("ArgTex:SetDesaturated(nil)").unwrap();
|
||||
assert!(!grey(&s), "nil clears");
|
||||
|
||||
// A number truncating to zero clears; a non-zero one greys.
|
||||
s.run("ArgTex:SetDesaturated(1) ArgTex:SetDesaturated(0)")
|
||||
.unwrap();
|
||||
assert!(!grey(&s), "0 clears — the number arm truncates to int");
|
||||
s.run("ArgTex:SetDesaturated(0.5)").unwrap();
|
||||
assert!(!grey(&s), "0.5 truncates to 0 and clears");
|
||||
|
||||
// Both booleans, and Dewdrop's `true` (the call 98 corpus addons reach).
|
||||
s.run("ArgTex:SetDesaturated(true)").unwrap();
|
||||
assert!(grey(&s), "true greys");
|
||||
s.run("ArgTex:SetDesaturated(false)").unwrap();
|
||||
assert!(!grey(&s), "false clears");
|
||||
}
|
||||
|
||||
/// The draw gate is the TEXTURE slot, never the colour: `0x7706e0` tests `+0xcc` and emits NOTHING
|
||||
/// when it is empty, whatever the vertex colour holds. Since the tint deliberately survives
|
||||
/// `SetTexture(nil)` ("a tint outlives the art it was tinting"), a cleared region used to leak its
|
||||
/// tint out of extract as a solid plate — an occupied action button going empty on a character
|
||||
/// switch drew its surviving 1/1/1 usable-tint as a solid WHITE square (decision 1108; the
|
||||
/// 2026-07-10 grey wells were the same class).
|
||||
/// The draw gate is the texture slot, never the colour: `0x7706e0` emits nothing when `+0xcc` is
|
||||
/// empty, though the tint survives `SetTexture(nil)`.
|
||||
#[test]
|
||||
fn a_vertex_colour_without_a_texture_draws_nothing() {
|
||||
let mut s = script();
|
||||
@@ -762,7 +684,6 @@ fn a_vertex_colour_without_a_texture_draws_nothing() {
|
||||
};
|
||||
assert_eq!(drawn(&s), 1, "tinted art draws");
|
||||
|
||||
// The slot empties: the art clears, the tint stays (distinct storage) — and nothing draws.
|
||||
s.run("icon:SetTexture(nil)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return icon:GetVertexColor()")
|
||||
@@ -777,21 +698,8 @@ fn a_vertex_colour_without_a_texture_draws_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetTexture` ignores arguments past the path**, because the client's does.
|
||||
///
|
||||
/// The path form reads ONE argument (`0x770200`); only the colour form (`0x770360`) reads up to
|
||||
/// four, and a C function takes what it wants off the Lua stack and ignores the rest. We typed the
|
||||
/// trailing three as `Option<f32>` and so RAISED on a stray extra — `bad argument #3: error
|
||||
/// converting Lua boolean to f32` — where the client silently accepts.
|
||||
///
|
||||
/// The live case is `_LazyPig/LazyPigMenu.lua:182`:
|
||||
/// `texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`. The `true` is
|
||||
/// meaningless in 1.12; the addon reached us only once the survey began seating the addon registry.
|
||||
///
|
||||
/// Asserted as "does not raise", which is the whole claim — there is no `GetTexture` binding to read
|
||||
/// the path back through, and inventing one to satisfy a test would be the tail wagging the dog.
|
||||
/// The colour form is exercised alongside so the fix cannot have made it lax: a non-numeric channel
|
||||
/// must take the same default a missing one does, exactly as `lua_tonumber` of a non-number is 0.
|
||||
/// `SetTexture`'s path form reads one argument (`0x770200`) and its colour form up to four
|
||||
/// (`0x770360`), ignoring the rest; a non-numeric channel reads as `lua_tonumber` reads it, 0.
|
||||
#[test]
|
||||
fn set_texture_ignores_arguments_past_the_path_like_the_client_does() {
|
||||
let s = script();
|
||||
@@ -804,25 +712,15 @@ fn set_texture_ignores_arguments_past_the_path_like_the_client_does() {
|
||||
)
|
||||
.expect("a stray extra argument must not raise — the client ignores it");
|
||||
|
||||
// The colour form, with a boolean and a numeric string among the channels.
|
||||
s.run("t:SetTexture(0.25, '0.5', true)")
|
||||
.expect("the colour form must tolerate what lua_tonumber tolerates");
|
||||
|
||||
// And the ordinary shapes still work.
|
||||
s.run("t:SetTexture(nil) t:SetTexture('') t:SetTexture(1, 0, 0, 1)")
|
||||
.expect("clear, blank and the plain colour form are unaffected");
|
||||
}
|
||||
|
||||
/// **`SetGradientAlpha` / `SetGradient` exist, store both stops, and paint.**
|
||||
///
|
||||
/// These were the single wall in front of the corpus's largest family: `FuBar\FuBar_Panel.lua:144`
|
||||
/// calls `SetGradientAlpha` while building the bar, so all 20 FuBar plugins died there — right after
|
||||
/// the chunk-name/`debugstack` fix got them that far.
|
||||
///
|
||||
/// Asserted on the region PAINTING (a gradient-only region must emit a quad, because the client
|
||||
/// generates the gradient into the same texture slot the colour form fills) and on the midpoint
|
||||
/// being what a one-tint quad shows. The full gradient stays on the region for a renderer that
|
||||
/// grows a second stop.
|
||||
/// A gradient fills the texture slot the colour form fills, so a gradient-only region paints;
|
||||
/// both stops are stored, and the quad paints their midpoint, not the gradient.
|
||||
#[test]
|
||||
fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
|
||||
let mut s = script();
|
||||
@@ -850,7 +748,7 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
|
||||
"a region carrying only a gradient must paint, at the midpoint of its two stops"
|
||||
);
|
||||
|
||||
// The alpha-less twin: both stops opaque, and a non-"VERTICAL" token is horizontal.
|
||||
// Any orientation token but "VERTICAL" is horizontal.
|
||||
s.run("t:SetGradient('HORIZONTAL', 1, 0, 0, 0, 0, 1)")
|
||||
.expect("SetGradient takes six colour arguments and no alpha");
|
||||
s.resolve();
|
||||
@@ -865,11 +763,8 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The split itself: a Texture answers texture verbs and NOT text ones, and vice versa.**
|
||||
///
|
||||
/// Until this landed, one shared table meant a Texture answered `SetText` and a FontString answered
|
||||
/// `SetTexture` — a superset in both directions (Texture's map `0x87c128` is 22 entries,
|
||||
/// FontString's `0xcf5400` is 32, both tail-calling the Region map and stopping there).
|
||||
/// Texture's map (`0x87c128`, 22 entries) and FontString's (`0xcf5400`, 32) each answer their own
|
||||
/// verbs, then fall back to the Region map and stop there.
|
||||
#[test]
|
||||
fn the_two_region_leaves_answer_their_own_maps() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -887,8 +782,7 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
== "function"
|
||||
};
|
||||
|
||||
// Texture-only, and an asymmetry worth noting: `SetVertexColor` is on BOTH leaves while
|
||||
// `GetVertexColor` is Texture-only. No reasonable partition invents that.
|
||||
// Texture-only; `GetVertexColor` is here though `SetVertexColor` is on both leaves.
|
||||
for m in [
|
||||
"SetTexture",
|
||||
"GetTexture",
|
||||
@@ -902,8 +796,7 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
assert!(has(&s, "Tex", m), "a Texture answers {m}");
|
||||
assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}");
|
||||
}
|
||||
// `SetRotation` is 1.12's PLAYERMODEL verb (`0x84f1fc`/`0x505f00`), not a region one — it is in
|
||||
// neither leaf map, and ours used to put it on Texture.
|
||||
// `SetRotation` is 1.12's PlayerModel verb (`0x84f1fc`/`0x505f00`), in neither leaf map.
|
||||
for leaf in ["Tex", "Str"] {
|
||||
assert!(
|
||||
!has(&s, leaf, "SetRotation"),
|
||||
@@ -921,8 +814,7 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
assert!(has(&s, "Str", m), "a FontString answers {m}");
|
||||
assert!(!has(&s, "Tex", m), "a Texture must NOT answer {m}");
|
||||
}
|
||||
// On BOTH — and each leaf registers its own copy in the client, so these are NOT on the Region
|
||||
// map and must not be hoisted into it.
|
||||
// On both leaves, each registering its own copy, so not on the Region map.
|
||||
for m in [
|
||||
"SetVertexColor",
|
||||
"SetAlpha",
|
||||
@@ -936,19 +828,13 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
"{m} is on both leaves"
|
||||
);
|
||||
}
|
||||
// The Region map reaches both, through each leaf's own fallback.
|
||||
for m in crate::script::REGION_MAP_METHODS {
|
||||
assert!(
|
||||
has(&s, "Tex", m) && has(&s, "Str", m),
|
||||
"{m} is the Region map"
|
||||
);
|
||||
}
|
||||
// **`GetStringHeight` is GONE and must stay gone.** 1.12 has no such method on any table (0
|
||||
// hits in every encoding; the control `GetStringWidth` has 1), Blizzard's own FrameXML calls it
|
||||
// 0 times, and ours was a byte-identical duplicate of `GetHeight` — which is the method the
|
||||
// reference itself uses for this, falling through to the same cached measurement
|
||||
// `GetStringWidth` reads. Keeping the width and dropping the height is not an oversight: the
|
||||
// client really is asymmetric here.
|
||||
// 1.12 has no `GetStringHeight` on any table; `GetHeight` reads the same measurement.
|
||||
assert!(
|
||||
!has(&s, "Str", "GetStringHeight"),
|
||||
"1.12 has no GetStringHeight"
|
||||
@@ -967,12 +853,8 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
assert!(has(&s, "Str", "SetAlphaGradient") && !has(&s, "Tex", "SetAlphaGradient"));
|
||||
}
|
||||
|
||||
/// **`SetPortraitToTexture` is a GLOBAL in 1.12, not a Texture method.**
|
||||
///
|
||||
/// `reference/1.12-globals.tsv` marks it `engine`, and both of the reference's own call sites pass a
|
||||
/// texture NAME: `ContainerFrame.lua:419` and `MailFrame.lua:174`. The first is the one that binds
|
||||
/// us — we SOURCE `ContainerFrame.lua` off the patch chain, so the client's own file calls this
|
||||
/// global inside our VM.
|
||||
/// `SetPortraitToTexture` is a 1.12 global, not a Texture method; stock `ContainerFrame.lua:419`
|
||||
/// and `MailFrame.lua:174` pass it a texture name.
|
||||
#[test]
|
||||
fn set_portrait_to_texture_is_a_global_taking_a_name() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -988,33 +870,23 @@ fn set_portrait_to_texture_is_a_global_taking_a_name() {
|
||||
s.eval::<String>("return Port:GetTexture()").unwrap(),
|
||||
"Interface\\ContainerFrame\\KeyRing-Bag-Icon"
|
||||
);
|
||||
// The Texture METHOD is gone — 1.12's Texture map has no such entry.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(Port.SetPortraitToTexture)")
|
||||
.unwrap(),
|
||||
"nil",
|
||||
"1.12 has no Texture:SetPortraitToTexture — it is a global"
|
||||
);
|
||||
// An unknown name is not an error: the reference's callers compose names that may not exist
|
||||
// yet, and nothing here may raise on the sourced file's behalf.
|
||||
// An unknown name does not raise here; the reference raises "Couldn't find texture named"
|
||||
// (`0x48d7db`).
|
||||
s.run(r#"SetPortraitToTexture("NoSuchPortrait", "Interface\\X")"#)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`Region:GetWidth`/`GetHeight` are the VIRTUAL getters** — the same content-derived law the
|
||||
/// rect resolver calls, because the Lua bindings dispatch through the same geometry-vtable slots
|
||||
/// (`GetWidth 0x7a1e00` ends `ff 52 1c`, `GetHeight 0x7a2030` ends `ff 52 20`; decision 1670).
|
||||
///
|
||||
/// Both halves of the FontString row, each of which we had backwards:
|
||||
///
|
||||
/// * the **authored** value wins per axis — `0x772930`'s `jp 0x77294a` skips the measure entirely
|
||||
/// when the authored width is not `0.0`, so `<Size x="300" y="0"/>` reports 300 from the author
|
||||
/// and the height from the text (`CharacterNameText` is exactly that, in the reference's own
|
||||
/// file), where we used to report the 37-point measure;
|
||||
/// * on an axis authored `0` the **width** is the NATURAL, unwrapped extent — the very cell
|
||||
/// `GetStringWidth` returns (`0x772890`'s `[fs+0xfc]`) — while the **height** is the *wrapped*
|
||||
/// one (`0x7729b0`'s `[fs+0x100]`). We used to report the laid-out width for both.
|
||||
/// `Region:GetWidth`/`GetHeight` (`0x7a1e00`/`0x7a2030`) call the virtual size getters the rect
|
||||
/// resolver uses: a FontString's authored axis wins (`0x772930` skips the measure, `jp 0x77294a`),
|
||||
/// else its width is the unwrapped extent `GetStringWidth` reads (`0x772890`, `[fs+0xfc]`) and its
|
||||
/// height the wrapped one (`0x7729b0`, `[fs+0x100]`).
|
||||
#[test]
|
||||
fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_height() {
|
||||
let mut s = script();
|
||||
@@ -1051,12 +923,8 @@ fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_hei
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The floor, and the state the reference does not have. `0x772930`/`0x772a60` end in a one-unit
|
||||
/// clamp, so a genuinely empty string reads back **1**, never `0.0`. But a measure that has not
|
||||
/// LANDED is not an extent at all — it is the host round-trip a VM with **no measurer installed**
|
||||
/// still takes, which the reference has no equivalent of. A caller that guards
|
||||
/// `if h <= 0 then return end` reads that zero as "not yet"; flooring it would tell them to stop
|
||||
/// waiting. So: floor a known extent, not the absence of one.
|
||||
/// `0x772930`/`0x772a60` clamp to one unit, so an empty string reads 1. A host measure still in
|
||||
/// flight, a state the reference does not have, reads 0, so an `if h <= 0` guard keeps waiting.
|
||||
#[test]
|
||||
fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() {
|
||||
let mut s = script();
|
||||
@@ -1078,10 +946,8 @@ fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A TEXTURE's getters are the same virtual law with the other override behind them
|
||||
/// (`0x770720`/`0x770790`): the authored value when it is not exactly `0.0`, else the art's own
|
||||
/// texel extent, else `0.0` — and **no floor**, which only the FontString override carries. This
|
||||
/// is the getter half of 1662; before it, Lua saw `0` for a size the screen was already drawing.
|
||||
/// A Texture's size getters (`0x770720`/`0x770790`): the authored value unless exactly `0.0`, else
|
||||
/// the art's texel extent, else `0.0`, with no floor.
|
||||
#[test]
|
||||
fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
|
||||
let mut s = script();
|
||||
@@ -1106,21 +972,12 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **The three constructors' string arguments are FOUR different shapes, not one rule.**
|
||||
///
|
||||
/// The discriminator is **not** the argument's type — it is whether the binding *tests* its
|
||||
/// parser's return:
|
||||
///
|
||||
/// | position | fetch | a table there | a number there |
|
||||
/// |---|---|---|---|
|
||||
/// | `CreateFontString`/`CreateTexture` `name`, `layer` | `0x6f3510` → `0x6f3690`, untested | absent | **accepted, stringified** |
|
||||
/// | `CreateFontString`/`CreateTexture` `inherits` | raw `lua_type == LUA_TSTRING`, no coercion | ignored | **ignored** |
|
||||
/// | `CreateFrame` `name`, `inherits` | `0x6f3690` **unguarded** | absent | **accepted, stringified** |
|
||||
/// | `CreateFrame` `kind` | `0x6f3510`, tested | **raises** | accepted |
|
||||
///
|
||||
/// The last two rows are the same value taking opposite paths from argument order alone:
|
||||
/// `CreateFrame` coerces at `0x70613f` *before* its `cmp 4`, and `0x6f7cb1` retags the number's
|
||||
/// stack slot **in place**, so a number arrives at the gate already a string.
|
||||
/// The constructors' string arguments take four shapes, by whether the binding tests its parser's
|
||||
/// return: a region constructor's `name` and `layer` (`0x6f3510` → `0x6f3690`, untested) and
|
||||
/// `CreateFrame`'s `name` and `inherits` (`0x6f3690`, unguarded) read a table as absent and
|
||||
/// stringify a number; a region constructor's `inherits` (a raw `lua_type == LUA_TSTRING`) ignores
|
||||
/// both; `CreateFrame`'s `kind` (`0x6f3510`, tested) raises on a table and takes a number, which
|
||||
/// `CreateFrame` coerces in place before its gate (`0x70613f`, `0x6f7cb1`).
|
||||
#[test]
|
||||
fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
let mut s = script();
|
||||
@@ -1128,10 +985,7 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
s.run(r#"host = CreateFrame("Frame", "ArgHost", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// ── pfUI's line, verbatim. `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])` passes
|
||||
// the BUTTON as `inheritsFrom`. The real client constructs it, ignores the argument, and
|
||||
// logs nothing — this raised `bad argument #4` until 2026-08-30 and stopped pfUI, and with
|
||||
// it pfQuest, pfQuest-turtle and ShaguDPS.
|
||||
// ── A table in `inherits` is ignored and the string still built; pfUI passes a button there.
|
||||
s.run(r#"fs = ArgHost:CreateFontString(nil, "OVERLAY", ArgHost)"#)
|
||||
.unwrap_or_else(|e| panic!("a table in `inherits` must be ignored, not raise: {e}"));
|
||||
assert_eq!(
|
||||
@@ -1139,7 +993,6 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
"table",
|
||||
"...and the FontString is really constructed"
|
||||
);
|
||||
// Every other non-string in every region-constructor position: also silent, also constructed.
|
||||
for pos in ["{}, \"OVERLAY\"", "nil, {}", "true, false"] {
|
||||
for ctor in ["CreateFontString", "CreateTexture"] {
|
||||
s.run(&format!("r = ArgHost:{ctor}({pos})"))
|
||||
@@ -1148,15 +1001,14 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── A NUMBER is a string to `lua_isstring` — so it NAMES the region.
|
||||
// ── A number passes `lua_isstring`, so it names the region.
|
||||
s.run("named = ArgHost:CreateTexture(4242)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return named:GetName()").unwrap(),
|
||||
"4242",
|
||||
"a number in `name` is stringified, not dropped"
|
||||
);
|
||||
// ...but NOT in `inherits`, whose gate reads the raw tag and never coerces. A number there is
|
||||
// ignored, where the same number in CreateFrame's fourth argument would raise.
|
||||
// `inherits` reads the raw tag, so a number there is ignored.
|
||||
s.run("ArgHost:CreateTexture(nil, nil, 5)")
|
||||
.unwrap_or_else(|e| panic!("a number in a region ctor's `inherits` is ignored: {e}"));
|
||||
|
||||
@@ -1167,21 +1019,15 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
"77",
|
||||
"CreateFrame reads `name` through an UNGUARDED lua_tostring"
|
||||
);
|
||||
// ...and an unresolvable `inherits` still raises, which is the pre-existing contract and the
|
||||
// control for the changes above: loosening the OTHER positions must not have loosened this.
|
||||
assert!(
|
||||
s.run(r#"CreateFrame("Frame", nil, nil, "NoSuchTemplateAnywhere")"#)
|
||||
.is_err(),
|
||||
"an unresolvable template name still raises — the miss branch is luaL_error"
|
||||
);
|
||||
|
||||
// ── The layer argument is PERMISSIVE, and an unrecognised one is ARTWORK, not an error.
|
||||
// `0x6f18b0` returns 0 with its out-param unwritten and neither constructor reads the
|
||||
// result, so the pre-staged ARTWORK (2) stands. Matching is case-insensitive.
|
||||
// ── The layer: case-insensitive, and an unrecognised one leaves the pre-staged ARTWORK.
|
||||
s.run(r#"lay = ArgHost:CreateTexture(nil, "not-a-layer")"#)
|
||||
.unwrap_or_else(|e| panic!("an unrecognised layer must not raise: {e}"));
|
||||
// Read off the arena — 1.12 publishes no `GetDrawLayer`, so there is no Lua getter to ask,
|
||||
// the same reason the sequence scrub is read this way in `modelframe`'s tests.
|
||||
let layer_of = |s: &UiScript, name: &str| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
@@ -1209,12 +1055,9 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The token is CANONICAL on the way out — `"NPC"`, which the stock merchant, guild registrar
|
||||
/// and trade windows all write (`MerchantFrame.lua:68`, `GuildRegistrarFrame.lua:4`,
|
||||
/// `TradeFrame.lua:41`), binds as `"npc"`. The reference resolves every unit token
|
||||
/// case-insensitively (`0x515970`'s ten compares are all `_strnicmp`), and the app samples the
|
||||
/// booth by this exact string — a raw `"NPC"` matched no slot and left the ring empty on all
|
||||
/// three windows (decision 2022).
|
||||
/// `SetPortraitTexture` lowercases the token, which the app matches exactly: the reference reads
|
||||
/// unit tokens case-insensitively (`0x515970`, `_strnicmp`), and stock `MerchantFrame.lua:68`
|
||||
/// passes `"NPC"`.
|
||||
#[test]
|
||||
fn set_portrait_texture_folds_the_token_to_lowercase() {
|
||||
let mut s = script();
|
||||
@@ -1243,31 +1086,9 @@ fn set_portrait_texture_folds_the_token_to_lowercase() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`FontString:GetText` substitutes nil for an EMPTY string, and that substitution is the
|
||||
/// getter's own** — `0x79d690` reads the cell, and eleven bytes before the pushstring it does a
|
||||
/// FIRST-BYTE test and zeroes the pointer on `'\0'`:
|
||||
///
|
||||
/// ```text
|
||||
/// 79d735 test eax,eax / je 0x79d740 ; NULL -> substitute
|
||||
/// 79d73b cmp byte ptr [eax],0x0 ; the first-byte test
|
||||
/// 79d73e jne 0x79d742 ; non-empty -> keep
|
||||
/// 79d740 xor eax,eax ; EMPTY -> NULL
|
||||
/// 79d746 call 0x6f3890 ; NULL -> pushnil
|
||||
/// ```
|
||||
///
|
||||
/// So it cannot return `""`, whatever the cell holds — and the cell really does hold non-NULL
|
||||
/// empty strings, because `SetText 0x771d80` never writes NULL to `+0xf0` on any leg: NULL and
|
||||
/// `""` share one leg that truncates the buffer in place. Per binding, not per family:
|
||||
/// `Button:GetText 0x780e10` carries the same substitution (`0x780ec5`), `EditBox:GetText
|
||||
/// 0x7985c0` carries none (decision 2110).
|
||||
///
|
||||
/// The director's shape (Cartographer 2.02, the world map's hover label): the stock
|
||||
/// `WorldMapFrameAreaDescription` is blanked with `SetText("")` by `WorldMapPOI_OnEnter`/`_OnLeave`
|
||||
/// on **every** POI hover, and Cartographer's ZoneInfo reads
|
||||
/// `if WorldMapFrameAreaDescription:GetText() then` as "this POI has a status line, so put the
|
||||
/// zone's level range there instead of on the label". Answering `""` made that true forever after
|
||||
/// the first POI touch — the name lost its faction colour, the range moved to the description's own
|
||||
/// line beneath it, and nothing ever cleared it again.
|
||||
/// `FontString:GetText` (`0x79d690`) pushes nil for an empty string by a first-byte test
|
||||
/// (`0x79d73b`), though `SetText 0x771d80` keeps an empty buffer; `Button:GetText 0x780e10` does
|
||||
/// the same (`0x780ec5`), and `EditBox:GetText 0x7985c0` returns `""`.
|
||||
#[test]
|
||||
fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
let s = script();
|
||||
@@ -1284,15 +1105,12 @@ fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Never written, and every shape of an empty write, all nil.
|
||||
assert_eq!(text_of(&s, "fresh"), None);
|
||||
for write in [r#"fresh:SetText("")"#, r#"fresh:SetText(nil)"#] {
|
||||
s.run(write).unwrap();
|
||||
assert_eq!(text_of(&s, "fresh"), None, "after `{write}`");
|
||||
}
|
||||
|
||||
// A string that HAS held text still reads nil once blanked — the substitution is the getter's,
|
||||
// so it does not matter that the cell keeps a (truncated) buffer.
|
||||
s.run(r#"held:SetText("In Conflict")"#).unwrap();
|
||||
assert_eq!(text_of(&s, "held").as_deref(), Some("In Conflict"));
|
||||
s.run(r#"held:SetText("")"#).unwrap();
|
||||
@@ -1304,7 +1122,7 @@ fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
s.run(r#"held:SetText("back"); held:SetText(nil)"#).unwrap();
|
||||
assert_eq!(text_of(&s, "held"), None);
|
||||
|
||||
// Button:GetText carries the SAME substitution; Button:SetText(nil) is its own no-op guard.
|
||||
// `Button:SetText(nil)` leaves the label alone.
|
||||
s.run(
|
||||
r#"
|
||||
local b = CreateFrame("Button", "TextCellButton")
|
||||
@@ -1321,8 +1139,7 @@ fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
s.run(r#"TextCellButton:SetText("")"#).unwrap();
|
||||
assert_eq!(text_of(&s, "TextCellButton"), None, "an empty label is nil");
|
||||
|
||||
// And the EditBox is the counter-example that keeps this from being hoisted: stock
|
||||
// `MailFrame.lua` compares `GetText() == ""` and calls `strlen(GetText())` on one.
|
||||
// Stock `MailFrame.lua:521` compares an EditBox's `GetText() == ""`.
|
||||
s.run(
|
||||
r#"
|
||||
local e = CreateFrame("EditBox", "TextCellEdit")
|
||||
@@ -1338,18 +1155,8 @@ fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A `SetTexture` the host cannot resolve leaves the region's art alone** — it does not overwrite
|
||||
/// it with the path that failed (decision 2124).
|
||||
///
|
||||
/// The reference's load-failure arm is explicit about it: `0x770288 cmp [ebp-4],2; jl` →
|
||||
/// `0x77028e`–`0x7702b2` releases the handle it just built and returns 0 **without ever touching
|
||||
/// `+0xcc`**. Ours stored the path first and used the
|
||||
/// probe's verdict only for the return value, so a mistyped or not-yet-shipped path erased the art
|
||||
/// it failed to replace — `GetTexture()` echoed the miss, and the extract dropped the quad, so the
|
||||
/// region went blank with nothing said anywhere.
|
||||
///
|
||||
/// The control that must not change is the second half: a resolvable path still replaces, and a VM
|
||||
/// with **no probe at all** still stores, because it has no backend to ask.
|
||||
/// A path that fails to load leaves the art alone (`0x770288` → `0x77028e`-`0x7702b2` returns 0
|
||||
/// without touching `+0xcc`); with no probe installed, every path is stored.
|
||||
#[test]
|
||||
fn an_unresolvable_set_texture_keeps_the_art_the_region_had() {
|
||||
let mut s = script();
|
||||
@@ -1367,7 +1174,6 @@ fn an_unresolvable_set_texture_keeps_the_art_the_region_had() {
|
||||
"Interface\\Real"
|
||||
);
|
||||
|
||||
// The miss: nil back, and the art it could not replace is still there.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return t:SetTexture("Interface\\Nope") == nil"#)
|
||||
.unwrap(),
|
||||
@@ -1379,7 +1185,6 @@ fn an_unresolvable_set_texture_keeps_the_art_the_region_had() {
|
||||
"the failed load overwrote the art the region was holding"
|
||||
);
|
||||
|
||||
// The control: a hit still replaces, and an explicit clear still clears.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return t:SetTexture("Interface\\Other") == 1"#)
|
||||
.unwrap());
|
||||
@@ -1391,15 +1196,8 @@ fn an_unresolvable_set_texture_keeps_the_art_the_region_had() {
|
||||
assert!(s.eval::<bool>("return t:GetTexture() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `Texture:GetBlendMode()` — ONE string, the reference's own spelling, round-tripping whatever
|
||||
/// `SetBlendMode` was given (`0x79a890`/`0x79a950`, table `0x87c128`, argc 1, arity 1, kinds
|
||||
/// `(string?)`).
|
||||
///
|
||||
/// The untouched default is asserted alongside the round trip because it is the load-bearing half:
|
||||
/// `ShaguTweaks/mods/dark-ui-elements.lua:169` guards a recolour with
|
||||
/// `region.GetBlendMode and region:GetBlendMode() == "ADD"`, so the answer an ordinary texture gives
|
||||
/// decides whether the reference's additive art is left alone. `"BLEND"` is the CSimpleTexture
|
||||
/// ctor's `[+0xd0] = 2` (`0x76fc64`).
|
||||
/// `Texture:GetBlendMode` (`0x79a890`, table `0x87c128`) answers one string, the mode
|
||||
/// `SetBlendMode` (`0x79a950`) set, `"BLEND"` by default (the ctor's `[+0xd0] = 2`, `0x76fc64`).
|
||||
#[test]
|
||||
fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
@@ -1411,7 +1209,6 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Arity 1 and kind string, measured the way the shape gate measures it.
|
||||
assert_eq!(s.arity("BTex:GetBlendMode()").unwrap(), 1, "arity 1");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(BTex:GetBlendMode())")
|
||||
@@ -1425,7 +1222,6 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
"an untouched texture is the ctor's mode 2"
|
||||
);
|
||||
|
||||
// Every one of the enum's five round-trips, including the three the renderer flattens.
|
||||
for mode in ["DISABLE", "ALPHAKEY", "BLEND", "ADD", "MOD"] {
|
||||
s.run(&format!(r#"BTex:SetBlendMode("{mode}")"#)).unwrap();
|
||||
assert_eq!(
|
||||
@@ -1434,13 +1230,12 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
"round trip through the setter"
|
||||
);
|
||||
}
|
||||
// Case-insensitive in, canonical out — the setter coerces, the getter answers the table's name.
|
||||
// Case-insensitive in, canonical out.
|
||||
s.run(r#"BTex:SetBlendMode("add")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BTex:GetBlendMode()").unwrap(),
|
||||
"ADD"
|
||||
);
|
||||
// A name outside the enum leaves the mode alone rather than inventing one.
|
||||
s.run(r#"BTex:SetBlendMode("NOT_A_MODE")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BTex:GetBlendMode()").unwrap(),
|
||||
@@ -1448,7 +1243,6 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
"an unknown name changes nothing"
|
||||
);
|
||||
|
||||
// A FontString is not a Texture: neither half of the pair is on its map.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local fs = BlendOwner:CreateFontString("BStr", "ARTWORK")
|
||||
@@ -1457,14 +1251,8 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `Texture:GetTexCoordModifiesRect()` — the 1.12 predicate return, `1`/`nil` and never a Lua
|
||||
/// boolean (`0x79c120`, table `0x87c128`, argc 1, arity 1, kinds `(nil) | (number)`; decision 2118).
|
||||
/// `pfUI/modules/thirdparty-tbc.lua:319` calls it bare on a Texture.
|
||||
///
|
||||
/// The flag's effect on the region's rect is deliberately NOT wired (see
|
||||
/// `RegionData::tex_coord_modifies_rect`), so this asserts the state and the shape — and asserts
|
||||
/// the un-wiring too, by checking that setting the flag moves no rect: a future change that wires
|
||||
/// the geometry will fail here and have to say so.
|
||||
/// `Texture:GetTexCoordModifiesRect` (`0x79c120`, table `0x87c128`) answers `1` or nil. The flag is
|
||||
/// stored, not applied: on the reference it makes `SetTexCoord` re-derive the rect (`0x770462`).
|
||||
#[test]
|
||||
fn tex_coord_modifies_rect_is_one_slash_nil_and_moves_no_rect_yet() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
@@ -1508,8 +1296,7 @@ fn tex_coord_modifies_rect_is_one_slash_nil_and_moves_no_rect_yet() {
|
||||
"kind number, never boolean"
|
||||
);
|
||||
|
||||
// The flag is state only: a SetTexCoord under it still leaves the rect where the anchors and
|
||||
// the size put it. When the geometry leg is built, this assertion is the one that must change.
|
||||
// The assertion that changes when the rect re-derivation is built.
|
||||
s.run("TCMTex:SetTexCoord(0, 0.25, 0, 0.5)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
//! **The resize-bounds quad**, pinned to the reference's own storage (`CLayoutFrame 0x767680`).
|
||||
//!
|
||||
//! Every assertion here is one the first cut of this code got *wrong* by writing the plausible
|
||||
//! thing instead of the read thing — which is why they are pinned rather than trusted:
|
||||
//!
|
||||
//! | the plausible thing | the byte thing |
|
||||
//! |---|---|
|
||||
//! | an unset bound is "no bound" and reads back `nil` | all four fields are `0.0`, the getters push two numbers |
|
||||
//! | a floor of `1.0` keeps a drag sane | there is **no floor** — a drag goes through zero into negatives |
|
||||
//! | a bound is a bound | only an exactly-`0.0` bound is the disable sentinel; a **negative** one clamps |
|
||||
//! | `min > max` is a caller error to reconcile | nothing reconciles it; **max wins** |
|
||||
//! | setting a bound fixes a frame already outside it | nothing happens until the next drag tick |
|
||||
//! | `StartSizing("CENTER")` grips no edge, so it is a no-op | it is the **move** arm, and it is never bounded |
|
||||
//! The resize-bounds quad, as the reference stores it (`CLayoutFrame 0x767680`).
|
||||
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A resizable 200×100 frame planted at its BOTTOMLEFT, the shape the sibling sizing test uses.
|
||||
/// A resizable 200×100 frame planted at its BOTTOMLEFT.
|
||||
fn sizer() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
@@ -31,7 +19,6 @@ fn sizer() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// The getters answer **two numbers**, `0, 0` on a frame nobody bounded — never `nil`.
|
||||
#[test]
|
||||
fn a_virgin_frames_bounds_read_back_as_zero_not_nil() {
|
||||
let s = sizer();
|
||||
@@ -57,8 +44,7 @@ fn a_virgin_frames_bounds_read_back_as_zero_not_nil() {
|
||||
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
|
||||
(400.0, 300.0)
|
||||
);
|
||||
// Setting one pair rewrites the other verbatim — the reference reads all four and writes all
|
||||
// four, so the untouched pair round-trips rather than being cleared.
|
||||
// The reference writes all four fields, so the untouched pair round-trips.
|
||||
s.run("Sizer:SetMinResize(10, 10)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
|
||||
@@ -66,8 +52,6 @@ fn a_virgin_frames_bounds_read_back_as_zero_not_nil() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The argument gate is `lua_isnumber` — a numeric **string** passes; everything else raises the
|
||||
/// reference's own `Usage:` text, naming the frame.
|
||||
#[test]
|
||||
fn the_setters_gate_is_lua_isnumber_and_the_usage_text_is_the_references() {
|
||||
let s = sizer();
|
||||
@@ -77,7 +61,7 @@ fn the_setters_gate_is_lua_isnumber_and_the_usage_text_is_the_references() {
|
||||
(120.0, 60.0),
|
||||
"a numeric string is coerced, as 5.0's lua_isnumber does"
|
||||
);
|
||||
// Arguments past the third are ignored — no upper arity check.
|
||||
// Arguments past the third are ignored.
|
||||
s.run("Sizer:SetMaxResize(400, 300, 999, \"x\")").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
|
||||
@@ -101,17 +85,13 @@ fn the_setters_gate_is_lua_isnumber_and_the_usage_text_is_the_references() {
|
||||
assert!(err.contains("Usage: Sizer:SetMaxResize(maxWidth, maxHeight)"));
|
||||
}
|
||||
|
||||
/// **A drag clamps against the bounds, and the anchor stops exactly on them.**
|
||||
///
|
||||
/// The second half is the rebate: without it the size pins at the bound while the delta keeps
|
||||
/// feeding the anchor, so a window held past its minimum stops shrinking and starts *walking*.
|
||||
/// A saturated grip stops the gripped edge on the bound rather than letting the frame walk.
|
||||
#[test]
|
||||
fn a_drag_clamps_and_the_planted_edge_stops_on_the_bound() {
|
||||
let mut s = sizer();
|
||||
s.run("Sizer:SetMinResize(150, 60) Sizer:SetMaxResize(260, 300)")
|
||||
.unwrap();
|
||||
|
||||
// RIGHT grip, dragged far right: width saturates at the max, the left edge never moves.
|
||||
s.mouse_move(300.0, 100.0);
|
||||
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
|
||||
s.mouse_move(900.0, 100.0);
|
||||
@@ -120,8 +100,7 @@ fn a_drag_clamps_and_the_planted_edge_stops_on_the_bound() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
|
||||
// LEFT grip, dragged far right: width saturates at the MIN — and the left edge lands exactly
|
||||
// on it (100 + 260 − 150 = 210), not wherever the raw cursor delta would have carried it.
|
||||
// LEFT grip dragged far right: width saturates at the min, the left edge at 100 + 260 - 150.
|
||||
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
|
||||
s.mouse_move(300.0, 100.0);
|
||||
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
|
||||
@@ -138,7 +117,6 @@ fn a_drag_clamps_and_the_planted_edge_stops_on_the_bound() {
|
||||
right_before,
|
||||
"the ungripped edge must not move"
|
||||
);
|
||||
// Dragging further while saturated moves nothing at all.
|
||||
s.mouse_move(1500.0, 100.0);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -148,7 +126,7 @@ fn a_drag_clamps_and_the_planted_edge_stops_on_the_bound() {
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
}
|
||||
|
||||
/// **`0.0` is the disable sentinel, per field** — and a NEGATIVE bound is live.
|
||||
/// Only an exact 0.0 is the disable sentinel, per field.
|
||||
#[test]
|
||||
fn zero_disables_and_a_negative_bound_still_clamps() {
|
||||
let mut s = sizer();
|
||||
@@ -167,7 +145,7 @@ fn zero_disables_and_a_negative_bound_still_clamps() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetHeight()").unwrap(), 90.0);
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
|
||||
// A negative maximum is NOT a sentinel: it clamps, and the width really does go negative.
|
||||
// A negative maximum clamps, and the width goes negative.
|
||||
s.run("Sizer:SetMaxResize(-50, 0) Sizer:SetMinResize(0, 0)")
|
||||
.unwrap();
|
||||
s.mouse_move(300.0, 100.0);
|
||||
@@ -177,7 +155,6 @@ fn zero_disables_and_a_negative_bound_still_clamps() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), -50.0);
|
||||
}
|
||||
|
||||
/// With no bound at all there is **no floor** — the client has no `1.0` and neither do we.
|
||||
#[test]
|
||||
fn an_unbounded_drag_goes_through_zero() {
|
||||
let mut s = sizer();
|
||||
@@ -192,7 +169,7 @@ fn an_unbounded_drag_goes_through_zero() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`min > max` ends at `max`** — min is applied first, then max, and nothing reconciles them.
|
||||
/// Min is applied first, then max, and nothing reconciles them.
|
||||
#[test]
|
||||
fn a_min_above_the_max_resolves_to_the_max() {
|
||||
let mut s = sizer();
|
||||
@@ -205,8 +182,7 @@ fn a_min_above_the_max_resolves_to_the_max() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 200.0);
|
||||
}
|
||||
|
||||
/// **Setting a bound a frame already violates does nothing** until the next drag tick — the setter
|
||||
/// does not touch the size, and no other write path consults the bounds.
|
||||
/// Only a drag tick consults the bounds; the setter and every other write path do not.
|
||||
#[test]
|
||||
fn a_bound_set_late_does_not_resize_the_frame() {
|
||||
let mut s = sizer();
|
||||
@@ -214,12 +190,10 @@ fn a_bound_set_late_does_not_resize_the_frame() {
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 200.0);
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetHeight()").unwrap(), 100.0);
|
||||
// A programmatic SetWidth past the bound is not clamped either — VERIFIED: no layout vtable
|
||||
// in the client overrides the plain, non-clamping SetWidth/SetHeight.
|
||||
// No layout vtable in the client overrides the plain, non-clamping SetWidth/SetHeight.
|
||||
s.run("Sizer:SetWidth(50)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 50.0);
|
||||
// The first drag tick is what snaps it into range.
|
||||
s.mouse_move(300.0, 100.0);
|
||||
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
|
||||
s.mouse_move(301.0, 100.0);
|
||||
@@ -227,7 +201,7 @@ fn a_bound_set_late_does_not_resize_the_frame() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 400.0);
|
||||
}
|
||||
|
||||
/// **`StartSizing("CENTER")` translates the frame and is never bounded** — the pump's case 4.
|
||||
/// `StartSizing("CENTER")` is the sizing pump's case 4, a move.
|
||||
#[test]
|
||||
fn a_center_grip_moves_the_frame_and_ignores_the_bounds() {
|
||||
let mut s = sizer();
|
||||
@@ -252,8 +226,7 @@ fn a_center_grip_moves_the_frame_and_ignores_the_bounds() {
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 140.0);
|
||||
}
|
||||
|
||||
/// `<ResizeBounds>` in XML reaches the same four fields — and a block naming only `<minResize>`
|
||||
/// **resets the max pair to unbounded**, because the client writes all four unconditionally.
|
||||
/// The client's `<ResizeBounds>` writes all four fields, so a lone `<minResize>` zeroes the max.
|
||||
#[test]
|
||||
fn the_xml_element_writes_both_pairs() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -280,8 +253,7 @@ fn the_xml_element_writes_both_pairs() {
|
||||
.unwrap(),
|
||||
(608.0, 400.0)
|
||||
);
|
||||
// An authored `<Size>` outside the authored bounds survives load unchanged — neither path
|
||||
// clamps, whatever the document order.
|
||||
// An authored `<Size>` outside the bounds survives load unclamped.
|
||||
assert_eq!(s.eval::<f32>("return Chatty:GetWidth()").unwrap(), 400.0);
|
||||
|
||||
let partial = benilla_ui_doc(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
//! ScrollFrame (decision 0112 — the ScrollFrame mechanism): the scroll-child layout override, the
|
||||
//! live vertical-scroll range, the ScrollFrame clip on `extract`, and the clip-gated `hit_test`.
|
||||
//! ScrollFrame: the scroll-child layout override, the live scroll ranges, the clip on `extract`
|
||||
//! and the clip-gated `hit_test`.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Rect;
|
||||
use crate::script::QuadContent;
|
||||
|
||||
/// The rect of the first Texture quad whose path equals `marker` (a bare texture with no anchors of
|
||||
/// its own draws at exactly its owner frame's resolved rect — the cheapest way to read a frame's
|
||||
/// resolved rect back out through the public `extract` surface).
|
||||
/// The rect of the first Texture quad whose path is `marker`.
|
||||
fn marker_rect(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<Rect> {
|
||||
quads.iter().find_map(|q| match &q.content {
|
||||
QuadContent::Texture { path: Some(p), .. } if p == marker => q.rect,
|
||||
@@ -15,8 +13,7 @@ fn marker_rect(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<R
|
||||
})
|
||||
}
|
||||
|
||||
/// `Some(clip)` when a marker quad was found (`clip` itself `Option<Rect>`); `None` if no such
|
||||
/// marker quad exists at all (a test-authoring mistake, not a legitimate "unclipped").
|
||||
/// The clip of the first Texture quad whose path is `marker`; `None` if there is no such quad.
|
||||
fn marker_clip(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<Option<Rect>> {
|
||||
quads.iter().find_map(|q| match &q.content {
|
||||
QuadContent::Texture { path: Some(p), .. } if p == marker => Some(q.clip),
|
||||
@@ -72,11 +69,8 @@ fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The sign convention (verified against the design's own worked example): frame top 500, vertical
|
||||
/// 40 ⇒ child top 540. Also covers 0, an offset PAST the range and one below zero — both stored and
|
||||
/// applied verbatim, since the reference's `0x786db0` never reads the range (decision 2017) — and
|
||||
/// that `SetScrollChild(nil)` restores the child's own authored anchor (never mutated — the
|
||||
/// override is a local map).
|
||||
/// A positive offset lifts the child; offsets past the range or below zero are stored and applied
|
||||
/// as given, since `0x786db0` never reads the range.
|
||||
#[test]
|
||||
fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
let mut s = script();
|
||||
@@ -119,9 +113,7 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
"vertical=40 -> child top = frame top + 40 (a positive offset lifts the child)"
|
||||
);
|
||||
|
||||
// range = child_h(600) - frame_h(200) = 400, and the offset is NOT clamped to it: 450 is
|
||||
// stored and applied as 450. (Keeping the bar inside the range is FrameXML's job, through the
|
||||
// Slider's [min, max] — the engine has no opinion.)
|
||||
// The range is 600 - 200 = 400; keeping the bar inside it is the FrameXML slider's job.
|
||||
s.run("SF:SetVerticalScroll(450)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -135,7 +127,6 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
Some(950.0),
|
||||
"applied verbatim: frame top 500 + 450"
|
||||
);
|
||||
// Nor at zero.
|
||||
s.run("SF:SetVerticalScroll(-30)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -149,8 +140,7 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
"a negative offset lowers the child: frame top 500 - 30"
|
||||
);
|
||||
|
||||
// SetScrollChild(nil): the override stops being computed — the child's own authored anchor
|
||||
// (TOPLEFT, screen, +20,-20) re-applies with no restore step needed (it was never touched).
|
||||
// Clearing the child stops the override, and its own untouched anchor applies again.
|
||||
s.run("SF:SetScrollChild(nil)").unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
@@ -161,11 +151,8 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's `0x786db0` is gated on the offset actually CHANGING (a compare against the
|
||||
/// stored value, nothing else): a `SetVerticalScroll` to the value already held re-lays nothing out
|
||||
/// and fires no `OnVerticalScroll`. The same shape as the Slider's `SetValue` change-gate (decision
|
||||
/// 0250) — the other half of what keeps the bar ↔ frame wiring from ringing — and a value past the
|
||||
/// range is a change like any other.
|
||||
/// `0x786db0` acts only when the offset differs from the stored one; a value past the range is
|
||||
/// still a change.
|
||||
#[test]
|
||||
fn set_vertical_scroll_fires_on_vertical_scroll_only_on_a_change() {
|
||||
let mut s = script();
|
||||
@@ -211,7 +198,6 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Before resolve: no resolved rects yet -> 0.
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
0.0,
|
||||
@@ -225,7 +211,6 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
|
||||
"600 - 200"
|
||||
);
|
||||
|
||||
// A child shorter than the frame: 0, not negative.
|
||||
s.run("Child:SetWidth(300); Child:SetHeight(150)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -234,7 +219,6 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
|
||||
"child shorter than frame -> 0, not negative"
|
||||
);
|
||||
|
||||
// No child at all: 0.
|
||||
s.run("SF:SetScrollChild(nil)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
@@ -243,11 +227,8 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
|
||||
);
|
||||
}
|
||||
|
||||
/// The range is in LOCAL units, not screen px — `child:GetHeight() - self:GetHeight()`, the
|
||||
/// contract the method states. It has to be: `SetVerticalScroll` becomes the child's anchor
|
||||
/// y-offset, which the solver multiplies by the child's scale, so a screen-px range against a
|
||||
/// local-unit offset makes a SCALED frame stop short of its own end and strand the tail of its
|
||||
/// content. (Found on the era-scaled options window's page scroll, which lost 22% of its travel.)
|
||||
/// The range is `child:GetHeight() - self:GetHeight()` in local units, like the offset, which
|
||||
/// becomes the child's anchor y-offset and is scaled with it.
|
||||
#[test]
|
||||
fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
let mut s = script();
|
||||
@@ -276,8 +257,6 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
400.0,
|
||||
"…which is exactly what the widget contract says it is"
|
||||
);
|
||||
// And the clamp agrees, so scrolling to the range really reaches the end: the child's bottom
|
||||
// lands on the frame's.
|
||||
s.run("SF:SetVerticalScroll(400)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -293,10 +272,8 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `OnVerticalScroll` carries the value AS STORED — past the range too (decision 2017: the
|
||||
/// reference's `0x786db0` fires with `[+0x328]`, which it never clamps) — under the reference's
|
||||
/// handler-firing conventions (`0x704f10`), and `UpdateScrollChildRect` fires
|
||||
/// `OnScrollRangeChanged` with the live range.
|
||||
/// `0x786db0` fires `OnVerticalScroll` with the unclamped stored value `[+0x328]`, under the
|
||||
/// handler conventions of `0x704f10`.
|
||||
#[test]
|
||||
fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
|
||||
let mut s = script();
|
||||
@@ -337,12 +314,8 @@ fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Era idiom `UpdateScrollChildRect` exists to serve — resize the content, then immediately ask
|
||||
/// for the new range, in ONE script tick with no intervening [`UiScript::resolve`] call in between
|
||||
/// (exactly how the app's own drive loop runs Lua: `ui_script/mod.rs` resolves once per FRAME, not
|
||||
/// once per script statement) — must see the JUST-set size, not the last frame's stale `resolved`
|
||||
/// rect. Before `UpdateScrollChildRect` forced its own fresh resolve, this fired the OLD (here,
|
||||
/// zero) range on the very call whose whole job is reporting the new one.
|
||||
/// `UpdateScrollChildRect` resolves for itself, so a resize earlier in the same tick counts; the
|
||||
/// app resolves only once per frame.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve() {
|
||||
let mut s = script();
|
||||
@@ -376,17 +349,12 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve(
|
||||
"UpdateScrollChildRect must resolve the JUST-set height itself, not report the stale \
|
||||
(pre-resize) range from the last full UiScript::resolve"
|
||||
);
|
||||
// GetVerticalScrollRange() also benefits (same fresh `resolved`), and a same-tick
|
||||
// SetVerticalScroll now clamps against the true, current range instead of the stale one.
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
400.0
|
||||
);
|
||||
}
|
||||
|
||||
/// The clip on `extract`: the scroll child's own quads, and a grandchild region nested arbitrarily
|
||||
/// deep under it, carry `clip = Some(the scrollframe's resolved rect)`; a sibling outside the
|
||||
/// ScrollFrame entirely carries `None`.
|
||||
#[test]
|
||||
fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped() {
|
||||
let mut s = script();
|
||||
@@ -436,9 +404,6 @@ fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped(
|
||||
);
|
||||
}
|
||||
|
||||
/// Nested ScrollFrames intersect their rects (decision 0112 §4): an inner ScrollFrame living inside
|
||||
/// an outer one's scroll child clips its own content to the intersection of both rects, not just
|
||||
/// the innermost.
|
||||
#[test]
|
||||
fn nested_scrollframes_intersect_their_clip_rects() {
|
||||
let mut s = script();
|
||||
@@ -474,8 +439,7 @@ fn nested_scrollframes_intersect_their_clip_rects() {
|
||||
let quads = s.extract();
|
||||
|
||||
let outer_rect = Rect::new(250.0, 0.0, 550.0, 400.0);
|
||||
// Inner's OWN rect (unclipped by itself): TOPLEFT anchored to C1 (top 550) at (350,-30) -> top
|
||||
// 520, left 350; size 200x150 -> bottom 370, right 550.
|
||||
// Inner's own rect: TOPLEFT at C1's (350, -30), so top 520 and left 350, sized 200x150.
|
||||
let inner_rect = Rect::new(370.0, 350.0, 520.0, 550.0);
|
||||
let intersection = Rect::new(
|
||||
inner_rect.bottom.max(outer_rect.bottom),
|
||||
@@ -483,8 +447,7 @@ fn nested_scrollframes_intersect_their_clip_rects() {
|
||||
inner_rect.top.min(outer_rect.top),
|
||||
inner_rect.right.min(outer_rect.right),
|
||||
);
|
||||
// A genuine intersection, not just "pick the smaller rect": inner's right edge (550) is cut
|
||||
// back to outer's (400), while inner's tighter bottom/left/top edges win.
|
||||
// Outer cuts inner's right edge from 550 to 400; inner's other edges are tighter.
|
||||
assert_eq!(intersection, Rect::new(370.0, 350.0, 520.0, 400.0));
|
||||
|
||||
assert_eq!(
|
||||
@@ -509,9 +472,6 @@ fn nested_scrollframes_intersect_their_clip_rects() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hit-testing (decision 0112 §5): a button inside the scroll child, scrolled out of the frame's
|
||||
/// rect, must not hit even though its own resolved rect contains the cursor; scrolled into view, it
|
||||
/// hits normally.
|
||||
#[test]
|
||||
fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() {
|
||||
let mut s = script();
|
||||
@@ -535,16 +495,14 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view()
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// vertical=0: child top 500, button top 500-400=100, bottom 80 — its OWN rect contains (25,90),
|
||||
// but that's well outside the frame's clip [300,500].
|
||||
// At 0 the button spans 80..100, outside the frame's clip of 300..500.
|
||||
assert_eq!(
|
||||
s.hit_test(25.0, 90.0),
|
||||
None,
|
||||
"the button's own rect contains the point, but the ScrollFrame's clip excludes it"
|
||||
);
|
||||
|
||||
// Scroll 250px: button top 500+250-400=350, bottom 330 — now inside both its own rect AND the
|
||||
// frame's clip window.
|
||||
// Scrolled by 250 the button spans 330..350, inside the clip.
|
||||
s.run("SF:SetVerticalScroll(250)").unwrap();
|
||||
s.resolve();
|
||||
assert!(
|
||||
@@ -553,9 +511,7 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view()
|
||||
);
|
||||
}
|
||||
|
||||
/// `UpdateScrollChildRect` right after the SetTexts that fill a pane reads a range that already
|
||||
/// counts the wrapped text — the reference's measure is synchronous, and with a font engine
|
||||
/// installed so is ours (1944; stock `QuestLog_UpdateQuestDetails` is the caller that found it).
|
||||
/// The reference measures text synchronously, as stock `QuestLog_UpdateQuestDetails` relies on.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() {
|
||||
struct Rows;
|
||||
@@ -593,16 +549,9 @@ fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range()
|
||||
);
|
||||
}
|
||||
|
||||
/// The horizontal axis is the vertical one's byte-clone (`0x786d30` against `0x786db0`), so this is
|
||||
/// the vertical trio's test on the other axis: the offset is stored VERBATIM and re-anchors the
|
||||
/// child, `OnHorizontalScroll` fires only on a real change, and the range is
|
||||
/// `max(0, subtreeWidth - frameWidth)`.
|
||||
///
|
||||
/// **The sign is the point.** `0x787100` hands both offsets to `SetPoint(TOPLEFT, self, TOPLEFT,
|
||||
/// +hScroll, +vScroll)` unnegated, and x grows right — so a positive horizontal pushes the child
|
||||
/// RIGHT where a positive vertical lifts it. `aux-addon/tabs/search/filter.lua:315-322` corroborates
|
||||
/// it from the caller's side (it bounds x into `[frameWidth - contentWidth - 10, 0]` and y into
|
||||
/// `[0, contentHeight - frameHeight]`), which is why it is asserted here rather than assumed.
|
||||
/// The horizontal axis clones the vertical (`0x786d30` against `0x786db0`); `0x787100` passes both
|
||||
/// offsets to `SetPoint(TOPLEFT, self, TOPLEFT, +h, +v)` unnegated, so a positive horizontal
|
||||
/// offset pushes the child right.
|
||||
#[test]
|
||||
fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
let mut s = script();
|
||||
@@ -640,7 +589,6 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
"horizontal=0 -> child left = frame left"
|
||||
);
|
||||
|
||||
// Positive pushes the child RIGHT (the reference's unnegated xOff) …
|
||||
s.run("HSF:SetHorizontalScroll(40)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -648,7 +596,6 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
Some(40.0),
|
||||
"a positive horizontal offset moves the child right"
|
||||
);
|
||||
// … and it is the NEGATIVE direction that scrolls right, which is the half aux-addon relies on.
|
||||
s.run("HSF:SetHorizontalScroll(-60)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -657,8 +604,7 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
"a negative horizontal offset scrolls right"
|
||||
);
|
||||
|
||||
// No clamp — the range is 500-300 = 200 and 450 is stored and applied whole (decision 2017's
|
||||
// law, which is `0x786d30`'s as much as `0x786db0`'s: neither reads the range).
|
||||
// Neither `0x786d30` nor `0x786db0` reads the range, so 450 is stored whole.
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return HSF:GetHorizontalScrollRange()")
|
||||
.unwrap(),
|
||||
@@ -673,7 +619,6 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
"stored verbatim, past the range"
|
||||
);
|
||||
|
||||
// Arity/kind, the way the shape gate measures them.
|
||||
assert_eq!(s.arity("HSF:GetHorizontalScroll()").unwrap(), 1);
|
||||
assert_eq!(s.arity("HSF:GetHorizontalScrollRange()").unwrap(), 1);
|
||||
assert_eq!(
|
||||
@@ -692,7 +637,7 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
"the setter answers nothing"
|
||||
);
|
||||
|
||||
// The change-gate: four setter calls moved the value (40, -60, 450, and 450 again is not one).
|
||||
// Four setter calls, but the second 450 is no change.
|
||||
let fired: Vec<f32> = (1..=s.eval::<i64>("return table.getn(hfired)").unwrap())
|
||||
.map(|i| s.eval::<f32>(&format!("return hfired[{i}]")).unwrap())
|
||||
.collect();
|
||||
@@ -702,7 +647,6 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
"OnHorizontalScroll fires once per ACTUAL change, with the new offset"
|
||||
);
|
||||
|
||||
// The two axes are independent state, not one offset behind two names.
|
||||
s.run("HSF:SetVerticalScroll(25)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return HSF:GetHorizontalScroll()").unwrap(),
|
||||
@@ -714,11 +658,8 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `UpdateScrollChildRect` notifies `OnScrollRangeChanged(self, xRange, yRange)` — **horizontal
|
||||
/// first**. Byte-derived, not guessed: the vertical result leaves `0x786e30` on top of the x87
|
||||
/// stack, is `fstp`'d first and so pushed deepest, landing as `arg2` (easy to read the other way
|
||||
/// round, which is why it is asserted here). `arg1` was a hardcoded `0.0` for as long as
|
||||
/// there was no horizontal range to put in it.
|
||||
/// The vertical range leaves `0x786e30` on top of the x87 stack and is `fstp`'d first, so it is
|
||||
/// pushed deepest and lands as `arg2`.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_reports_both_ranges_horizontal_first() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! The runtime `SetParent` law — strata/level re-assignment, the hide→show round-trip, and the
|
||||
//! binding's error surface (`SetParent 0x7a1550`; decision 1323). Every test drives the
|
||||
//! production Lua binding; the arena split
|
||||
//! (`reparent_begin`/`reparent_finish`) is exercised through it.
|
||||
//! Runtime `SetParent` (`0x7a1550`): strata and level re-assignment, the hide and show round-trip,
|
||||
//! and the binding's errors.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// `strata := parent.strata`, `level := parent.level + 1` (`0x76ab5a`/`0x76ab65`) — and the
|
||||
/// subtree is NOT re-based (`propagate = 0`): an existing child keeps its absolute level, landing
|
||||
/// below its own parent, exactly as shipped.
|
||||
/// `strata := parent.strata`, `level := parent.level + 1` (`0x76ab5a`/`0x76ab65`); the subtree's
|
||||
/// levels are not re-based (`propagate = 0`), so a child can land below its own parent.
|
||||
#[test]
|
||||
fn reparent_relevels_the_moved_frame_only() {
|
||||
let s = script();
|
||||
@@ -44,8 +41,7 @@ fn reparent_relevels_the_moved_frame_only() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetParent(nil)` is a RESET — strata MEDIUM, level 0 (`0x76aba3`/`0x76abac`) — never "keep
|
||||
/// current".
|
||||
/// `SetParent(nil)` resets to strata MEDIUM, level 0 (`0x76aba3`/`0x76abac`).
|
||||
#[test]
|
||||
fn reparent_to_nil_resets_strata_and_level() {
|
||||
let s = script();
|
||||
@@ -66,11 +62,8 @@ fn reparent_to_nil_resets_strata_and_level() {
|
||||
assert_eq!(s.eval::<i64>("return F:GetFrameLevel()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A reparent of an effectively-visible frame is a hide→show round-trip: `OnHide` fires (under the
|
||||
/// OLD parent), then `OnShow` refires down the subtree — and an `OnShow` doing
|
||||
/// `SetFrameLevel(GetParent():GetFrameLevel()+1)` lands the child back above its parent. That
|
||||
/// hand-repair is AtlasLoot's own idiom and the reason its browse panel works on the reference —
|
||||
/// the round-trip is what makes it run.
|
||||
/// A visible frame's reparent fires `OnHide` under the old parent, then `OnShow` down the subtree,
|
||||
/// where AtlasLoot's `SetFrameLevel(GetParent():GetFrameLevel()+1)` lifts a child back up.
|
||||
#[test]
|
||||
fn a_visible_reparent_refires_onhide_then_onshow() {
|
||||
let s = script();
|
||||
@@ -115,10 +108,8 @@ fn a_visible_reparent_refires_onhide_then_onshow() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A reparent of a hidden frame fires neither event, and does not recompute effective visibility:
|
||||
/// the `+0xd4` cascade only runs inside the show half (`0x76abfd` gates on the captured `ebx`), so
|
||||
/// a shown-but-invisible frame moved under a visible parent STAYS effectively invisible until
|
||||
/// something shows it. Shipped behaviour, byte-verified — not an oversight.
|
||||
/// The `+0xd4` visibility cascade runs only in the show half (`0x76abfd` gates on the captured
|
||||
/// `ebx`), so a hidden frame moved under a visible parent stays invisible until shown.
|
||||
#[test]
|
||||
fn a_hidden_reparent_fires_nothing_and_leaves_visibility_stale() {
|
||||
let s = script();
|
||||
@@ -145,9 +136,7 @@ fn a_hidden_reparent_fires_nothing_and_leaves_visibility_stale() {
|
||||
.unwrap(),
|
||||
"shown bit intact, effective visibility stale-false under the visible new parent"
|
||||
);
|
||||
// An explicit Show is what recomputes it — but Show() no-ops while the bit is already set, so
|
||||
// a real transition needs the toggle. The Hide half fires no OnHide (the frame was already
|
||||
// effectively invisible — transition-gated); only the Show half's false→true fires.
|
||||
// Show() no-ops while the shown bit is set, so it takes a toggle; only its Show half fires.
|
||||
s.run("STF:Hide(); STF:Show()").unwrap();
|
||||
assert!(s.eval::<bool>("return STF:IsVisible()").unwrap());
|
||||
assert_eq!(
|
||||
@@ -157,9 +146,8 @@ fn a_hidden_reparent_fires_nothing_and_leaves_visibility_stale() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The binding's error surface: a cycle RAISES (`0x87cb14` — including `newParent == self`), an
|
||||
/// unresolvable name raises `Couldn't find region named`, and an ABSENT argument is the same raise
|
||||
/// — not the nil path. The same-parent call is a total no-op that fires nothing.
|
||||
/// A cycle, self included, raises (`0x87cb14`); an absent argument takes the bad-name raise, not
|
||||
/// the nil path.
|
||||
#[test]
|
||||
fn the_binding_raises_on_cycle_bad_name_and_absent_argument() {
|
||||
let s = script();
|
||||
@@ -185,7 +173,7 @@ fn the_binding_raises_on_cycle_bad_name_and_absent_argument() {
|
||||
let absent = s.run("A:SetParent()").unwrap_err().to_string();
|
||||
assert!(absent.contains("Couldn't find region named"), "{absent}");
|
||||
|
||||
// Same parent: the total no-op — no events, no level rewrite.
|
||||
// B's parent is already A: the same-parent call is a total no-op, firing nothing.
|
||||
s.run(
|
||||
r#"
|
||||
B:SetFrameLevel(9)
|
||||
@@ -204,16 +192,8 @@ fn the_binding_raises_on_cycle_bad_name_and_absent_argument() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A reparent under a scale change still moves the child's resolved rect** — the falsifier for
|
||||
/// decision 2314's claim that `SetParent` is a value-only write the ledger can NAME.
|
||||
///
|
||||
/// The gate's two tiers police each other under `WOW_LAYOUT_VERIFY`, which is forced on for this
|
||||
/// crate's tests: an incremental pass that names its nodes is re-run from scratch and the rects
|
||||
/// must be identical. So this test does not have to assert the epoch bookkeeping — it only has to
|
||||
/// make the reparent *observable in geometry*, which is what the conservative touch used to buy
|
||||
/// wholesale. A child anchored inside a parent inherits `parentScale · ownScale` as its
|
||||
/// `LayoutInput.scale`; move it to a parent with a different scale and both its offsets and its
|
||||
/// span change.
|
||||
/// Under `WOW_LAYOUT_VERIFY`, on for this crate's tests, every incremental pass is checked against
|
||||
/// a full one, so the test only needs a reparent that changes the child's scale.
|
||||
#[test]
|
||||
fn a_reparent_under_a_scale_change_moves_the_childs_rect() {
|
||||
let mut s = script();
|
||||
@@ -235,9 +215,7 @@ fn a_reparent_under_a_scale_change_moves_the_childs_rect() {
|
||||
.eval("return ScaleKid:GetLeft(), ScaleKid:GetBottom(), ScaleKid:GetWidth()")
|
||||
.unwrap();
|
||||
|
||||
// The whole point: the write between the two reads is the reparent, and the read after it must
|
||||
// see it. Scale 2 doubles the offsets and the span in screen units — and `GetWidth` reports in
|
||||
// the frame's OWN scaled space, so the width reads back unchanged while the edges move.
|
||||
// Own-space reads (`GetLeft`, `GetWidth`) stay put while the screen rect doubles.
|
||||
s.run("kid:SetParent(two)").unwrap();
|
||||
s.resolve();
|
||||
let at_two: (f32, f32, f32) = s
|
||||
@@ -250,7 +228,7 @@ fn a_reparent_under_a_scale_change_moves_the_childs_rect() {
|
||||
(10.0, 20.0, 100.0),
|
||||
"own-space coordinates are scale-relative, so these do not move"
|
||||
);
|
||||
// …but the SCREEN rect does. Read it off the extraction, which is in screen units.
|
||||
// The extraction is in screen units.
|
||||
let widths: Vec<f32> = s
|
||||
.extract()
|
||||
.iter()
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
//! The `SimpleHTML` widget end to end — the markup engine's *model* half.
|
||||
//!
|
||||
//! The parse itself (strict XML, the three fallback routes, the collapse, the splices) is unit
|
||||
//! tested in `script::simplehtml::parse`, against the byte law directly. What is asserted here is
|
||||
//! everything that only exists once the blocks are real regions: the anchor chain, the width
|
||||
//! snapshot, the element-font resolution and its H1→P fallback, the spacing step, the free on
|
||||
//! rebuild, and the 19-name Lua table.
|
||||
//! The `SimpleHTML` widget once its blocks are regions: the anchor chain, block widths, element
|
||||
//! fonts and their `P` fallback, spacing, the free on rebuild and the 19-name Lua table.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Point;
|
||||
use crate::script::{Model, UiScript};
|
||||
use crate::widget::RegionKind;
|
||||
|
||||
/// One block as a reader would describe it: what it says, how it is justified, what face it is in,
|
||||
/// how wide it was told to be, and where it hangs from.
|
||||
/// One block as a reader would describe it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Blk {
|
||||
text: Option<String>,
|
||||
@@ -20,21 +14,18 @@ struct Blk {
|
||||
justify_h: &'static str,
|
||||
font: Option<String>,
|
||||
font_height: Option<f32>,
|
||||
/// The `size` the widget wrote: `(frame width, 0)` for a text block — a zero height is how the
|
||||
/// measure round-trip is asked for the intrinsic one.
|
||||
/// `(frame width, 0)` for a text block: a 0 height takes the measured one.
|
||||
size: Option<(f32, f32)>,
|
||||
/// `(own point, relative point, x, y, target)`, `None` when the block carries no anchor at all.
|
||||
/// `(own point, relative point, x, y, target)`.
|
||||
anchor: Option<(Point, Point, f32, f32, Rel)>,
|
||||
}
|
||||
|
||||
/// What a block's single anchor points at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Rel {
|
||||
/// The SimpleHTML frame itself — only block 0 ever does this.
|
||||
/// The SimpleHTML frame; only block 0 anchors to it.
|
||||
Frame,
|
||||
/// A previous block, by index in the block list.
|
||||
Block(usize),
|
||||
/// Something else entirely (never expected; a failure would print it).
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -73,8 +64,6 @@ fn blocks(s: &UiScript, name: &str) -> Vec<Blk> {
|
||||
font_height: d.font_height,
|
||||
size: d.size,
|
||||
anchor,
|
||||
// A texture block has no text; keeping the kind out of the struct and asserting it
|
||||
// here keeps the expected-value literals readable.
|
||||
}
|
||||
.tap_kind(kind)
|
||||
})
|
||||
@@ -82,9 +71,7 @@ fn blocks(s: &UiScript, name: &str) -> Vec<Blk> {
|
||||
}
|
||||
|
||||
impl Blk {
|
||||
/// A tiny consistency check folded into the read: a text block is a FontString, an image block
|
||||
/// is a Texture. A regression that made blocks the wrong region kind would otherwise pass
|
||||
/// every text assertion below.
|
||||
/// Asserts a text block is a FontString and an image block a Texture.
|
||||
fn tap_kind(self, kind: RegionKind) -> Blk {
|
||||
match (&self.text, &self.texture) {
|
||||
(Some(_), None) => assert_eq!(kind, RegionKind::FontString),
|
||||
@@ -95,7 +82,6 @@ impl Blk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the strings, for the tests that only care about the walk.
|
||||
fn texts(s: &UiScript, name: &str) -> Vec<String> {
|
||||
blocks(s, name)
|
||||
.into_iter()
|
||||
@@ -103,8 +89,7 @@ fn texts(s: &UiScript, name: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many regions the frame owns in total — the free-on-rebuild check's instrument. A block that
|
||||
/// was orphaned rather than destroyed still sits in its owner's region list and still draws.
|
||||
/// The frame's region count, which still counts a block orphaned rather than freed.
|
||||
fn region_count(s: &UiScript, name: &str) -> usize {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -112,8 +97,8 @@ fn region_count(s: &UiScript, name: &str) -> usize {
|
||||
model.arena.frame(fh).expect("live frame").regions.len()
|
||||
}
|
||||
|
||||
/// A `SimpleHTML` named `Page`, 270×304 like the reference's `ItemTextPageText`, with one font
|
||||
/// object behind it (the `<FontString inherits="ItemTextFontNormal"/>` shape).
|
||||
/// A `SimpleHTML` named `Page`, 270×304 and in `ItemTextFontNormal` like the stock
|
||||
/// `ItemTextPageText` (`ItemTextFrame.xml:203`).
|
||||
fn page(s: &UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -129,13 +114,9 @@ fn page(s: &UiScript) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Answer every pending measure at 16px a line and 100 wide, so the resolved rects below are
|
||||
/// arithmetic a reader can check.
|
||||
///
|
||||
/// The line count is the client's own kernel `0x5c2070`, not "breaks + 1": it counts a line per
|
||||
/// class-2 token consumed and exits at the terminator, so a **trailing** break opens no new line
|
||||
/// and the one-byte `"\n"` a `<BR/>` block carries is **one** line, not two. An empty string
|
||||
/// never enters the loop body at all and measures 0 — the empty-`<P>` edge.
|
||||
/// Answer every pending measure at 16 px a line and 100 wide, with the reference's line count
|
||||
/// (`0x5c2070`): a trailing break opens no line, so a `<BR/>` block's `"\n"` is one line, and an
|
||||
/// empty string is none.
|
||||
fn measure_at_16px(s: &mut UiScript) {
|
||||
s.resolve();
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
@@ -160,12 +141,9 @@ fn client_lines(text: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The walk, as blocks on the frame
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The walk, as blocks on the frame ──
|
||||
|
||||
/// A well-formed body: one block per tag, each carrying the tag's own `align` and — because only
|
||||
/// `<FontString>` was declared — every one of them in `P`'s face, the `<H1>` included.
|
||||
/// Each tag's block carries its `align`, and with only `P` declared every block is in `P`'s face.
|
||||
#[test]
|
||||
fn a_well_formed_body_is_one_block_per_tag() {
|
||||
let mut s = script();
|
||||
@@ -192,20 +170,18 @@ fn a_well_formed_body_is_one_block_per_tag() {
|
||||
("Right body.".to_string(), "RIGHT"),
|
||||
]
|
||||
);
|
||||
// `elementFont[1]`'s path is empty, so `0x78ae30` substitutes `elementFont[0]`. There
|
||||
// is NO header scaling anywhere in the TU: the H1 is the same 15px as the paragraphs.
|
||||
// `elementFont[1]` has no path, so `0x78ae30` substitutes `elementFont[0]`; nothing scales a
|
||||
// header.
|
||||
for blk in &b {
|
||||
assert_eq!(blk.font.as_deref(), Some("Fonts\\MORPHEUS.TTF"));
|
||||
assert_eq!(blk.font_height, Some(15.0));
|
||||
// `SetWidth(frame.GetWidth())`, and NO height — the intrinsic one comes from the measure.
|
||||
assert_eq!(blk.size, Some((270.0, 0.0)));
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The anchor chain (`CLayoutFrame::SetPoint 0x767c70`) and the flush default (`0x770d89` zeroes
|
||||
/// spacing): block 0 pins TOPLEFT→frame TOPLEFT, every later block TOPLEFT→**the previous block's**
|
||||
/// BOTTOMLEFT at `−spacing` — which is `0` out of the box, so the blocks touch.
|
||||
/// Block 0 pins TOPLEFT to the frame's TOPLEFT and each later block TOPLEFT to the previous one's
|
||||
/// BOTTOMLEFT at `-spacing` (`0x767c70`); spacing starts at 0 (`0x770d89`), so the blocks touch.
|
||||
#[test]
|
||||
fn blocks_chain_bottom_to_top_and_are_flush_at_spacing_zero() {
|
||||
let mut s = script();
|
||||
@@ -224,15 +200,13 @@ fn blocks_chain_bottom_to_top_and_are_flush_at_spacing_zero() {
|
||||
]
|
||||
);
|
||||
|
||||
// …and the resolved rects really do stack with no gap. Frame top is 600 (screen top, TOPLEFT
|
||||
// at 0,0); three 16px lines run 600→584→568→552.
|
||||
// The frame top is the screen top, 600; three 16 px lines run down to 552.
|
||||
measure_at_16px(&mut s);
|
||||
let tops = resolved_tops(&s, "Page");
|
||||
assert_eq!(tops, [(600.0, 584.0), (584.0, 568.0), (568.0, 552.0)]);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `SetSpacing` is the only thing that opens a gap, and it opens it by exactly its own value.
|
||||
#[test]
|
||||
fn spacing_steps_the_blocks_apart() {
|
||||
let mut s = script();
|
||||
@@ -264,8 +238,7 @@ fn spacing_steps_the_blocks_apart() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `<BR/>` at BODY level is its own `"\n"` block, and `0x5c2070` counts **one** line for it — one
|
||||
/// blank line, not two and not a paragraph gap.
|
||||
/// A BODY-level `<BR/>` is its own `"\n"` block, which `0x5c2070` counts as one line.
|
||||
#[test]
|
||||
fn a_body_level_br_is_exactly_one_blank_line() {
|
||||
let mut s = script();
|
||||
@@ -282,8 +255,7 @@ fn a_body_level_br_is_exactly_one_blank_line() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline `<BR/>` is a different animal: it splices the two bytes `|n` into the SAME block, which
|
||||
/// the font engine turns into a line break within it.
|
||||
/// An inline `<BR/>` splices `|n` into the enclosing block, a line break within it.
|
||||
#[test]
|
||||
fn an_inline_br_stays_inside_its_block() {
|
||||
let s = script();
|
||||
@@ -293,8 +265,8 @@ fn an_inline_br_stays_inside_its_block() {
|
||||
assert_eq!(texts(&s, "Page"), ["a|nb"]);
|
||||
}
|
||||
|
||||
/// All three fallback routes land on ONE block holding the RAW string — no collapse, so embedded
|
||||
/// newlines survive as real line breaks. This is the common case for a vmangos `page_text`.
|
||||
/// Each fallback route is one block holding the raw, uncollapsed string; most vmangos `page_text`
|
||||
/// rows are plain text and take it.
|
||||
#[test]
|
||||
fn the_three_fallback_routes_each_land_on_one_raw_block() {
|
||||
let s = script();
|
||||
@@ -317,7 +289,6 @@ fn the_three_fallback_routes_each_land_on_one_raw_block() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Malformed markup **falls back**; it never raises and never renders half a document.
|
||||
#[test]
|
||||
fn malformed_markup_falls_back_rather_than_erroring() {
|
||||
let s = script();
|
||||
@@ -333,8 +304,7 @@ fn malformed_markup_falls_back_rather_than_erroring() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A second `SetText` destroys the first parse's blocks.** Not hides, not orphans — the regions
|
||||
/// are gone from the frame, so nothing of the old page can draw behind the new one.
|
||||
/// A second `SetText` frees the first parse's regions, so nothing of the old page draws.
|
||||
#[test]
|
||||
fn a_second_set_text_replaces_the_blocks() {
|
||||
let mut s = script();
|
||||
@@ -363,9 +333,8 @@ fn a_second_set_text_replaces_the_blocks() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The real `page_text` shape `ItemTextFrame.xml`'s ItemTextPageText renders, driven through the
|
||||
/// widget: the `<H1>` at the `<P>` size, every block centred, each `<BR/>` one blank line, and the
|
||||
/// body's own inter-tag newlines adding nothing at all.
|
||||
/// A real `page_text` body: the `<H1>` at the `<P>` size, each `<BR/>` one blank line, and the
|
||||
/// newlines between tags adding nothing.
|
||||
#[test]
|
||||
fn the_page_text_body_renders_the_block_list_a_reader_would_draw() {
|
||||
let mut s = script();
|
||||
@@ -406,7 +375,7 @@ fn the_page_text_body_renders_the_block_list_a_reader_would_draw() {
|
||||
],
|
||||
"the H1 renders at the P size — nothing in the TU scales a header"
|
||||
);
|
||||
// Five blocks flush at 16px each: 600 → 520.
|
||||
// Five flush 16 px blocks: 600 down to 520.
|
||||
measure_at_16px(&mut s);
|
||||
let tops = resolved_tops(&s, "Page");
|
||||
assert_eq!(tops.first(), Some(&(600.0, 584.0)));
|
||||
@@ -414,14 +383,9 @@ fn the_page_text_body_renders_the_block_list_a_reader_would_draw() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The exact string the reader builds**, on the exact body B240 reports — `ItemTextFrame.xml`'s
|
||||
/// READY handler pads an authorless page as `"\n" .. ItemTextGetText() .. "\n"`, and that padding
|
||||
/// is the one thing standing between a formatted page and its raw markup.
|
||||
/// Whitespace before and after the root element is legal XML, so this must take the MARKUP path;
|
||||
/// if it ever takes the fallback, every book in the world silently renders as its own source.
|
||||
///
|
||||
/// The body is vmangos `page_text` 2676 verbatim — the *Alliance Military Ranks* plaque in
|
||||
/// Stormwind's Old Town, `GameObject` 3011, the object in the report's screenshots.
|
||||
/// `ItemTextFrame.lua:44` pads an authorless page as `"\n" .. ItemTextGetText() .. "\n"`, and
|
||||
/// whitespace around the root element is legal XML, so it still takes the markup path. The body is
|
||||
/// vmangos `page_text` 2676, the Alliance Military Ranks plaque (`GameObject` 3011).
|
||||
#[test]
|
||||
fn the_readers_own_newline_padding_still_parses_as_markup() {
|
||||
let mut s = script();
|
||||
@@ -460,7 +424,7 @@ fn the_readers_own_newline_padding_still_parses_as_markup() {
|
||||
"the padded page took the markup path — one block per tag, one blank line per <BR/>, and \
|
||||
BODY's own inter-tag newlines adding nothing"
|
||||
);
|
||||
// The falsification, stated: the fallback would put the WHOLE source in one block.
|
||||
// The fallback would put the whole source in one block.
|
||||
assert!(
|
||||
!texts[0].contains('<'),
|
||||
"a fallback would render the markup itself — the reported symptom"
|
||||
@@ -468,10 +432,8 @@ fn the_readers_own_newline_padding_still_parses_as_markup() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The other half of the same guard: `page_text` **1510** (the Crystal Pylon manual) ends
|
||||
/// `</HTML.` — a typo in Blizzard's own shipped data. Strict XML rejects it, so it falls back to
|
||||
/// raw markup **on the reference client too**, and ours must do the same rather than paper over
|
||||
/// it. This is the one of the world's 62 HTML bodies that is not well-formed.
|
||||
/// `page_text` 1510, the Crystal Pylon manual, ends `</HTML.`, so strict XML rejects it and the
|
||||
/// reference shows the raw markup too; it is the only malformed one of the 62 HTML bodies.
|
||||
#[test]
|
||||
fn blizzards_own_malformed_page_falls_back_exactly_as_the_reference_does() {
|
||||
let s = script();
|
||||
@@ -490,8 +452,6 @@ fn blizzards_own_malformed_page_falls_back_exactly_as_the_reference_does() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A declared header font **is** honoured — the fallback fires only on an element with no font
|
||||
/// from any source, so this is the control for the test above.
|
||||
#[test]
|
||||
fn a_declared_header_font_wins_over_the_p_fallback() {
|
||||
let s = script();
|
||||
@@ -516,8 +476,7 @@ fn a_declared_header_font_wins_over_the_p_fallback() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An `<IMG>` sizes itself, anchors by `align`, and — when it is NOT floated — reserves its own
|
||||
/// height in the flow while leaving `prevBlock` pointing at the last **text** block.
|
||||
/// An unfloated `<IMG>` reserves its height in the flow, but `prevBlock` stays the last text block.
|
||||
#[test]
|
||||
fn an_unfloated_image_reserves_height_without_becoming_the_anchor() {
|
||||
let s = script();
|
||||
@@ -542,19 +501,9 @@ fn an_unfloated_image_reserves_height_without_becoming_the_anchor() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **B342, at the engine.** The symptom: the Alliance crest on *A Treatise on Military Ranks*
|
||||
/// draws several times the reference's size, with the page's text over the top of it.
|
||||
///
|
||||
/// The body is `page_text` 2654 and its `<IMG>` carries **no `width=` and no `height=`**, so the
|
||||
/// block is sized `0 × 0`. In the reference that is not "no size": the resolver's size call is
|
||||
/// VIRTUAL, and `CSimpleTexture::GetWidth 0x770720` / `GetHeight 0x770790` answer an authored `0.0`
|
||||
/// with the loaded texture's own texel extent, through bit-for-bit the converter `<AbsDimension>`
|
||||
/// uses — **one texel is one FrameXML unit** (decision 1349).
|
||||
/// `PvPRankAlliance.blp` is 128×128, so the crest is a 128-unit square.
|
||||
///
|
||||
/// The falsification is the reported shape itself, asserted below: a zero span leaves the opposite
|
||||
/// edge unresolved (`combine_edge`'s `span != 0.0` leg), and the region sweep's owner-edge fallback
|
||||
/// then hands the image the rest of the **270-wide** page.
|
||||
/// The `<IMG>` in `page_text` 2654 has no `width=` or `height=`, and the reference's
|
||||
/// `CSimpleTexture::GetWidth 0x770720`/`GetHeight 0x770790` answer an authored 0 with the art's
|
||||
/// texel size, a texel to a FrameXML unit: the 128×128 crest is a 128-unit square.
|
||||
#[test]
|
||||
fn the_books_unsized_crest_is_its_arts_texel_square_not_the_whole_page() {
|
||||
const CREST: &str = "Interface\\PvPRankBadges\\PvPRankAlliance";
|
||||
@@ -596,18 +545,15 @@ fn the_books_unsized_crest_is_its_arts_texel_square_not_the_whole_page() {
|
||||
(128.0, 128.0),
|
||||
"a texel is a FrameXML unit: the 128x128 crest is a 128-unit square, not the page"
|
||||
);
|
||||
// The reported symptom, stated as the thing that must not come back: the page is 270 wide and
|
||||
// 304 tall, and the crest used to take all of the first and everything left of the second.
|
||||
// The page is 270 wide; the crest must not span it.
|
||||
assert!(
|
||||
rect.right - rect.left < 270.0,
|
||||
"the crest is stretched across the page width again"
|
||||
);
|
||||
}
|
||||
|
||||
/// The flow half of the same law (`0x78ad07`): an **unfloated** image reserves
|
||||
/// `texture.GetHeight()` — `CSimpleTexture`'s override — so one with no `height=` reserves
|
||||
/// its art's texel height, and the next text block hangs that far lower. A floated one (the book's
|
||||
/// `align="left"`) still reserves nothing, which is why its text overlaps it in both clients.
|
||||
/// An unfloated image reserves `GetHeight()` in the flow (`0x78ad07`), its art's texel height when
|
||||
/// it has no `height=`; a floated one reserves nothing, so text runs over it in the reference too.
|
||||
#[test]
|
||||
fn an_unsized_image_reserves_its_arts_texel_height_unless_it_is_floated() {
|
||||
let mut s = script();
|
||||
@@ -641,12 +587,8 @@ fn an_unsized_image_reserves_its_arts_texel_height_unless_it_is_floated() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A VM with no host oracle has no art to measure, so a zero-size image has **no span on either
|
||||
/// axis** — and with one corner pinned and nothing to add to it, it does not resolve at all. That
|
||||
/// is the reference's own answer for a texture with no `CGxTex*`: `combineEdge`'s legs both fail
|
||||
/// their `span != 0.0` test, `assemble 0x767a20` returns 0, and the texture emits a degenerate
|
||||
/// all-zero quad. Stated so the
|
||||
/// engine-less path cannot quietly go back to borrowing the page's width.
|
||||
/// With no size oracle a zero-size image has no span and does not resolve, as in the reference for
|
||||
/// a texture with no `CGxTex*`: `assemble 0x767a20` returns 0 and the quad is all zeros.
|
||||
#[test]
|
||||
fn without_a_size_oracle_an_unsized_image_does_not_resolve() {
|
||||
let mut s = script();
|
||||
@@ -666,12 +608,9 @@ fn without_a_size_oracle_an_unsized_image_does_not_resolve() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The Lua table
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The Lua table ──
|
||||
|
||||
/// The 19 names of `.data 0x87ba80`, and only those 19 — `GetText` is **not** one of them in
|
||||
/// build 5875, and a name a table does not carry is as wrong as a missing one.
|
||||
/// The 19 names of `.data 0x87ba80`, and only those: there is no `GetText`.
|
||||
#[test]
|
||||
fn the_method_table_is_the_nineteen_names() {
|
||||
let s = script();
|
||||
@@ -706,16 +645,15 @@ fn the_method_table_is_the_nineteen_names() {
|
||||
!s.eval::<bool>("return H.GetText ~= nil").unwrap(),
|
||||
"1.12.1's SimpleHTML table has no GetText"
|
||||
);
|
||||
// The names are the SimpleHTML's own — no other widget kind answers them.
|
||||
// The names are the SimpleHTML's own: a Frame does not answer them.
|
||||
s.run(r#"F = CreateFrame("Frame")"#).unwrap();
|
||||
assert!(!s
|
||||
.eval::<bool>("return F.SetHyperlinkFormat ~= nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The optional element-name argument (`0x795d80`): omitting it addresses `P`, the four names are
|
||||
/// case-insensitive, and a **string that is not one of the four is not removed** — it becomes the
|
||||
/// shared implementation's first real argument.
|
||||
/// The optional element name (`0x795d80`): absent means `P`, the four names match in any case, and
|
||||
/// any other string stays as the first real argument.
|
||||
#[test]
|
||||
fn the_element_name_argument_is_optional_case_insensitive_and_non_matching_falls_through() {
|
||||
let s = script();
|
||||
@@ -737,8 +675,7 @@ fn the_element_name_argument_is_optional_case_insensitive_and_non_matching_falls
|
||||
.unwrap(),
|
||||
("Fonts\\B.TTF".to_string(), 20.0)
|
||||
);
|
||||
// "h4" is not an element: it stays on the stack and is consumed as SetFont's PATH, so this
|
||||
// targets P and sets the face to the literal "h4".
|
||||
// "h4" is no element, so it is `SetFont`'s path and the call targets `P`.
|
||||
s.run(r#"H:SetFont("h4", 12)"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32)>("local p, h = H:GetFont(); return p, h")
|
||||
@@ -747,7 +684,6 @@ fn the_element_name_argument_is_optional_case_insensitive_and_non_matching_falls
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetHyperlinkFormat` governs how an `<A>` is spliced, and takes effect on the next parse.
|
||||
#[test]
|
||||
fn the_hyperlink_format_shapes_the_a_splice() {
|
||||
let s = script();
|
||||
@@ -767,12 +703,10 @@ fn the_hyperlink_format_shapes_the_a_splice() {
|
||||
.unwrap();
|
||||
assert_eq!(texts(&s, "Page"), ["see |cff33ff99|Hitem:1|h[this]|h|r"]);
|
||||
|
||||
// A non-string argument raises the reference's own usage string rather than no-opping.
|
||||
// A non-string argument raises the reference's usage string.
|
||||
assert!(s.run("Page:SetHyperlinkFormat(7)").is_err());
|
||||
}
|
||||
|
||||
/// The colour/shadow/justify getters answer per element, and `SetTextColor` reaches the blocks the
|
||||
/// next parse builds.
|
||||
#[test]
|
||||
fn the_paint_setters_land_on_the_element_and_reach_the_next_parse() {
|
||||
let s = script();
|
||||
@@ -791,7 +725,7 @@ fn the_paint_setters_land_on_the_element_and_reach_the_next_parse() {
|
||||
.unwrap(),
|
||||
(1.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
// P keeps the font object's own colour — the element arg really did scope the write.
|
||||
// `P` keeps the font object's colour: the element argument scoped the write.
|
||||
let (r, g, b, _) = s
|
||||
.eval::<(f32, f32, f32, f32)>("return Page:GetTextColor(\"P\")")
|
||||
.unwrap();
|
||||
@@ -808,13 +742,9 @@ fn the_paint_setters_land_on_the_element_and_reach_the_next_parse() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The empty-path fallback substitutes the WHOLE element font, colour included** — so an `H1`
|
||||
/// given a colour and nothing else renders in `P`'s colour, and its own red is never drawn.
|
||||
///
|
||||
/// That is the shape of `0x78ae29`–`0x78ae54`: the register holding `elementFont[elem]` is
|
||||
/// *replaced* by `elementFont[0]` before the single `SetFontObject` call, so the test is on the
|
||||
/// path and the consequence is on everything. Surprising, faithful, and exactly the sort of thing
|
||||
/// a re-implementation gets wrong by merging property-by-property instead.
|
||||
/// An element with no font path takes `elementFont[0]` whole, colour included: `0x78ae29` to
|
||||
/// `0x78ae54` swap the register before the one `SetFontObject`, so an `H1` given only a colour
|
||||
/// draws in `P`'s.
|
||||
#[test]
|
||||
fn an_element_with_no_font_of_its_own_loses_its_own_colour_too() {
|
||||
let s = script();
|
||||
@@ -826,8 +756,8 @@ fn an_element_with_no_font_of_its_own_loses_its_own_colour_too() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The getter still answers the red — the element font really does hold it (`0x795d3e` reads
|
||||
// `[this + idx*4 + 0x350]`, never a block).
|
||||
// The getter still answers red: it reads the element font, never a block (`0x795d3e` reads
|
||||
// `[this + idx*4 + 0x350]`).
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return Page:GetTextColor(\"H1\")")
|
||||
.unwrap(),
|
||||
@@ -839,7 +769,7 @@ fn an_element_with_no_font_of_its_own_loses_its_own_colour_too() {
|
||||
"the block took elementFont[0] wholesale, so H1's red never reaches it"
|
||||
);
|
||||
|
||||
// Give H1 a path of its own and the fallback stops firing — now the red draws.
|
||||
// With a path of its own, `H1` draws its red.
|
||||
s.run(
|
||||
r#"
|
||||
Page:SetFont("H1", "Fonts\\SKURRI.TTF", 24)
|
||||
@@ -850,8 +780,7 @@ fn an_element_with_no_font_of_its_own_loses_its_own_colour_too() {
|
||||
assert_eq!(block_color(&s, "Page", 0), Some([1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// The `vertex_color` of block `i` — a FontString has no texel, so its vertex colour IS the colour
|
||||
/// it draws.
|
||||
/// Block `i`'s `vertex_color`, which for a FontString is the colour it draws.
|
||||
fn block_color(s: &UiScript, name: &str, i: usize) -> Option<[f32; 4]> {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -860,14 +789,9 @@ fn block_color(s: &UiScript, name: &str, i: usize) -> Option<[f32; 4]> {
|
||||
model.region_data.get(&st.blocks[i])?.vertex_color
|
||||
}
|
||||
|
||||
/// The two halves of `SetFontObject`'s severance law, which the element font has to reproduce by
|
||||
/// hand because it resolves its object lazily rather than copying the paint at the call:
|
||||
///
|
||||
/// - a **re-point does not reset** the explicit mask (`CSimpleFont+0x2c`/`CSimpleFontString+0xD4`:
|
||||
/// the inheritMask bit a local setter clears is never restored), so a face set on the element
|
||||
/// survives being pointed at a different object;
|
||||
/// - the **nil form severs the link and leaves the paint standing** — it does not blank the
|
||||
/// element.
|
||||
/// `SetFontObject` on an element font: a re-point keeps what a local setter set (the inheritMask
|
||||
/// bit at `CSimpleFont+0x2c`/`CSimpleFontString+0xD4` is never restored), and nil unlinks the
|
||||
/// object but leaves the paint.
|
||||
#[test]
|
||||
fn set_font_object_keeps_local_overrides_and_the_nil_form_leaves_the_paint_standing() {
|
||||
let s = script();
|
||||
@@ -904,8 +828,8 @@ fn set_font_object_keeps_local_overrides_and_the_nil_form_leaves_the_paint_stand
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetJustifyH` is **inert for rendered text** — the byte law, not a gap: `0x78adb0` overwrites
|
||||
/// every block's justifyH with the tag's `align`. The getter still answers what was stored.
|
||||
/// `0x78adb0` gives every block its tag's `align`, so `SetJustifyH` is stored and answered but
|
||||
/// never drawn.
|
||||
#[test]
|
||||
fn set_justify_h_is_stored_and_answered_but_never_drawn() {
|
||||
let s = script();
|
||||
@@ -926,13 +850,11 @@ fn set_justify_h_is_stored_and_answered_but_never_drawn() {
|
||||
"LEFT",
|
||||
"the tag's absent align (LEFT) wins over the element font's justify"
|
||||
);
|
||||
// A non-token raises, exactly as `0x87c77c`'s usage arm does.
|
||||
// A non-token raises the usage string (`0x87c77c`).
|
||||
assert!(s.run(r#"Page:SetJustifyH("sideways")"#).is_err());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers that need the resolve
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── Helpers that need the resolve ──
|
||||
|
||||
/// Every block's resolved `(top, bottom)`, in block order.
|
||||
fn resolved_tops(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
|
||||
@@ -949,7 +871,6 @@ fn resolved_tops(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One block's resolved rect if it HAS one — the shape a region with no span reaches.
|
||||
fn maybe_resolved_rect(s: &UiScript, name: &str, block: usize) -> Option<crate::layout::Rect> {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -958,7 +879,6 @@ fn maybe_resolved_rect(s: &UiScript, name: &str, block: usize) -> Option<crate::
|
||||
model.region_resolved.get(&st.blocks[block]).copied()
|
||||
}
|
||||
|
||||
/// One block's resolved rect, by index in the block list.
|
||||
fn resolved_rect(s: &UiScript, name: &str, block: usize) -> crate::layout::Rect {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -970,7 +890,7 @@ fn resolved_rect(s: &UiScript, name: &str, block: usize) -> crate::layout::Rect
|
||||
.expect("block rect")
|
||||
}
|
||||
|
||||
/// The text of every quad the extract emits for the page — what is actually on screen.
|
||||
/// The text of every quad the extract emits: what is on screen.
|
||||
fn page_texts_on_screen(s: &UiScript) -> Vec<String> {
|
||||
s.extract()
|
||||
.into_iter()
|
||||
@@ -981,8 +901,7 @@ fn page_texts_on_screen(s: &UiScript) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A Rust string as a Lua long-bracket literal — the bodies here are full of quotes and
|
||||
/// backslashes, and escaping them twice is how a test asserts on the wrong string.
|
||||
/// A Rust string as a Lua long-bracket literal, so quotes and backslashes need no second escape.
|
||||
fn lua_str(s: &str) -> String {
|
||||
format!("[==[{s}]==]")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! `OnSizeChanged` — the base-map layout script (`0x76a0d0` `+0x120`), fired from the resolve pass.
|
||||
//!
|
||||
//! Two things are being pinned here, and they are different in kind. The **gate** is byte-verified:
|
||||
//! `ApplyRect 0x76b580` fires iff `|Δwidth| ≥ ε ∨ |Δheight| ≥ ε` with `ε = _DAT_008029d4`
|
||||
//! ([`crate::layout::SIZE_EPS`]), which is why a frame that merely MOVES fires nothing. The
|
||||
//! **seam** is ours: the client applies rects one at a time and fires per application, benilla
|
||||
//! solves the whole graph to a fixpoint, so we fire on the entry-vs-convergence diff — one fire per
|
||||
//! resolve per frame whose size actually moved, never one per solver round.
|
||||
//! `OnSizeChanged`, the base-map layout script (`0x76a0d0` `+0x120`). `ApplyRect 0x76b580` fires
|
||||
//! it only when width or height moves by `ε = _DAT_008029d4` ([`crate::layout::SIZE_EPS`]).
|
||||
//! Deviation: the client fires per rect application; we fire once per resolve on the size before
|
||||
//! and after, because our solver settles the whole graph to a fixpoint.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// The size moves ⇒ `OnSizeChanged(self, width, height)` fires with the NEW size; a pure move fires
|
||||
/// nothing (the byte-verified gate is on width/height, not on the rect).
|
||||
/// `OnSizeChanged(self, width, height)` carries the new size.
|
||||
#[test]
|
||||
fn a_resize_fires_on_size_changed_and_a_move_does_not() {
|
||||
let mut s = script();
|
||||
@@ -28,18 +23,15 @@ fn a_resize_fires_on_size_changed_and_a_move_does_not() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The FIRST layout is a change too: the client's cached rect starts zeroed, so its first
|
||||
// ApplyRect is 0×0 → 200×50 and does fire.
|
||||
// The client's cached rect starts zeroed, so the first layout fires too.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
assert_eq!(s.eval::<f64>("return w").unwrap(), 200.0);
|
||||
assert_eq!(s.eval::<f64>("return h").unwrap(), 50.0);
|
||||
|
||||
// A second resolve with nothing touched fires nothing — the size did not move.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
|
||||
// A MOVE is not a resize.
|
||||
s.run(r#"Panel:SetPoint("BOTTOMLEFT", 400, 300)"#).unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -48,7 +40,6 @@ fn a_resize_fires_on_size_changed_and_a_move_does_not() {
|
||||
"the rect moved but width/height did not — ApplyRect's gate is on the size alone"
|
||||
);
|
||||
|
||||
// A real resize does.
|
||||
s.run("Panel:SetHeight(120)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
@@ -57,10 +48,7 @@ fn a_resize_fires_on_size_changed_and_a_move_does_not() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// It fires for a size the frame did not set on itself — the case the corpus actually uses it for
|
||||
/// (`MoveAnything`, `CleanMinimap`, `FuBar`, `Outfitter`, `AckisRecipeList` all watch a frame whose
|
||||
/// size is decided by something else). Here the child is anchored to two edges of a parent that
|
||||
/// resizes, so its own `LayoutInput` never changes at all and only the SOLVE knows it moved.
|
||||
/// Addons watch frames sized by their anchors; only the solve knows this child moved.
|
||||
#[test]
|
||||
fn an_anchor_driven_resize_fires_it_too() {
|
||||
let mut s = script();
|
||||
@@ -93,11 +81,8 @@ fn an_anchor_driven_resize_fires_it_too() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The loop guard.** A handler that resizes its own frame is the classic `OnSizeChanged` hazard,
|
||||
/// and it must not spin the engine. The drain takes ONE batch per resolve
|
||||
/// ([`crate::script::event::fire_size_changes`]), so a handler that settles on a constant size
|
||||
/// settles — one extra fire for the size it forced, then silence — and one that never settles costs
|
||||
/// one fire per resolve rather than hanging inside a single call.
|
||||
/// The drain takes one batch per resolve ([`crate::script::event::fire_size_changes`]), so a
|
||||
/// handler that resizes its own frame costs one fire per resolve instead of hanging.
|
||||
#[test]
|
||||
fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
|
||||
let mut s = script();
|
||||
@@ -117,14 +102,12 @@ fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Resolve returns (does not hang) even though the handler dirties the layout from inside it.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
// The next resolve sees the handler's own 200×200 and fires once for it…
|
||||
// The next resolve fires once for the handler's own 200×200.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
assert_eq!(s.eval::<f64>("return Sq:GetHeight()").unwrap(), 200.0);
|
||||
// …and then it is stable: no further fires, however many passes run.
|
||||
for _ in 0..5 {
|
||||
s.resolve();
|
||||
}
|
||||
@@ -136,9 +119,6 @@ fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The regression guard that makes the `SCRIPT_KINDS` row real: `SetScript("OnSizeChanged", …)`
|
||||
/// stopped raising, and what replaced the error is a handler that actually runs — not a stored
|
||||
/// closure nobody ever calls.
|
||||
#[test]
|
||||
fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() {
|
||||
let mut s = script();
|
||||
@@ -160,15 +140,8 @@ fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() {
|
||||
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
|
||||
}
|
||||
|
||||
/// **The watch list is exactly the frames carrying the script.** The resolve's "before" snapshot
|
||||
/// reads `SetScript`'s maintained `on_size_changed_frames` rather than filtering the whole scripts
|
||||
/// map (decision 1634), so the list itself is now load-bearing: `SetScript(…, nil)` must remove
|
||||
/// the frame, and re-registering must not enrol it twice.
|
||||
///
|
||||
/// Asserted on the LIST, not on the fire count, and that is the point. A stale entry fires nothing
|
||||
/// — `fire` looks the handler up in Lua and finds nil — so behaviour hides the bug completely and
|
||||
/// the only symptom is the snapshot silently growing forever, one dead frame at a time. (Written
|
||||
/// as a fire-count test first; it passed with the `retain` deleted.)
|
||||
/// The resolve snapshots `on_size_changed_frames`, which `SetScript` maintains. Asserted on the
|
||||
/// list itself: a stale entry fires nothing, so only the list would show it.
|
||||
#[test]
|
||||
fn the_on_size_changed_watch_list_is_exactly_the_frames_carrying_the_script() {
|
||||
use crate::script::model::Model;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Slider (per-kind behavior; LoadXML `0x789580`, decision 0250). The value/step/orientation
|
||||
//! contract, the VERTICAL ctor default, the no-swap divergence from StatusBar, and the change-gate
|
||||
//! that keeps the real scrollbar wiring from recursing.
|
||||
//! Slider (LoadXML `0x789580`): value, step and orientation, the vertical default, the unswapped
|
||||
//! min/max, the value quantiser, and the change gate that keeps scrollbar wiring from recursing.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::QuadContent;
|
||||
|
||||
/// Find the thumb quad (the texture whose path carries `needle`) and return its
|
||||
/// `(left, right, bottom, top)` rect. Panics if absent — the render must emit it.
|
||||
/// The `(left, right, bottom, top)` rect of the thumb quad whose texture path contains `needle`.
|
||||
fn thumb_rect(quads: &[crate::script::ExtractedQuad], needle: &str) -> (f32, f32, f32, f32) {
|
||||
let q = quads
|
||||
.iter()
|
||||
@@ -37,15 +35,14 @@ fn slider_thumb_draws_at_value_fraction_along_the_track() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// value=min: the thumb sits flush at the track TOP (a scrollbar at 0 is scrolled to the top).
|
||||
// travel = 100 - 16 = 84; thumb centered on cx=108 -> [100,116], top edge at track.top=200.
|
||||
// value=min puts the thumb flush at the track top (a scrollbar at 0 is scrolled up); travel
|
||||
// 100 - 16 = 84, thumb centered on x=108 -> [100,116], top edge at 200.
|
||||
assert_eq!(
|
||||
thumb_rect(&s.extract(), "Knob"),
|
||||
(100.0, 116.0, 184.0, 200.0),
|
||||
"value=min: thumb flush at track top, centered on the cross-axis"
|
||||
);
|
||||
|
||||
// value=max: the thumb sits flush at the track BOTTOM.
|
||||
s.run(r#"SlRender:SetValue(100)"#).unwrap();
|
||||
assert_eq!(
|
||||
thumb_rect(&s.extract(), "Knob"),
|
||||
@@ -53,7 +50,6 @@ fn slider_thumb_draws_at_value_fraction_along_the_track() {
|
||||
"value=max: thumb flush at track bottom"
|
||||
);
|
||||
|
||||
// Halfway: thumb centered in the track (midpoint 150).
|
||||
s.run(r#"SlRender:SetValue(50)"#).unwrap();
|
||||
let (_, _, bottom, top) = thumb_rect(&s.extract(), "Knob");
|
||||
assert_eq!(
|
||||
@@ -65,8 +61,7 @@ fn slider_thumb_draws_at_value_fraction_along_the_track() {
|
||||
|
||||
#[test]
|
||||
fn slider_thumb_drag_maps_cursor_to_value() {
|
||||
// The same vertical scrollbar as the render test: track y in [100, 200], 16x16 thumb. At value=0
|
||||
// the thumb spans y[184,200] (its top edge at the track top), centered on x=108.
|
||||
// The render test's scrollbar: track y in [100, 200]; at value 0 the thumb spans y[184,200].
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
@@ -83,8 +78,7 @@ fn slider_thumb_drag_maps_cursor_to_value() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Grab the thumb at its center (108, 192) and drag down the track. travel = 84; the grab keeps
|
||||
// the same thumb point under the cursor, so the value tracks the cursor absolutely.
|
||||
// Grab the thumb's center (108, 192); the grab keeps that thumb point under the cursor.
|
||||
s.mouse_button(108.0, 192.0, "LeftButton", true);
|
||||
s.mouse_move(108.0, 150.0);
|
||||
let v: f32 = s.eval("return SlDrag:GetValue()").unwrap();
|
||||
@@ -96,7 +90,6 @@ fn slider_thumb_drag_maps_cursor_to_value() {
|
||||
"cursor at the track bottom -> value 100 (clamped)"
|
||||
);
|
||||
|
||||
// Release ends the capture: a later move must not move the value.
|
||||
s.mouse_button(108.0, 108.0, "LeftButton", false);
|
||||
s.mouse_move(108.0, 192.0);
|
||||
let v: f32 = s.eval("return SlDrag:GetValue()").unwrap();
|
||||
@@ -123,10 +116,8 @@ fn slider_track_press_seats_the_thumb_and_a_mouseless_slider_ignores_it() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// A press on the TRACK below the thumb (108, 120) — inside the slider, not on the thumb
|
||||
// [184,200] — SEATS the thumb's center under the cursor and fires OnValueChanged from the
|
||||
// press itself (0989's track-press law; 0250 §5 covered only the thumb grab). Track
|
||||
// y in [100, 200], travel 84: cursor 120 → thumb top 128 → fraction (200−128)/84 = 72/84.
|
||||
// A press on the track below the thumb seats the thumb's center under the cursor and fires
|
||||
// OnValueChanged from the press: cursor 120 → thumb top 128 → fraction (200−128)/84 = 72/84.
|
||||
s.mouse_button(108.0, 120.0, "LeftButton", true);
|
||||
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
|
||||
assert!(
|
||||
@@ -136,17 +127,13 @@ fn slider_track_press_seats_the_thumb_and_a_mouseless_slider_ignores_it() {
|
||||
let n: usize = s.eval("return table.getn(fired)").unwrap();
|
||||
assert_eq!(n, 1, "the jump fired OnValueChanged once, from the press");
|
||||
|
||||
// The SAME press keeps dragging — the capture began at the track press.
|
||||
s.mouse_move(108.0, 108.0);
|
||||
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
|
||||
assert_eq!(v, 100.0, "the gesture drags on without re-grabbing");
|
||||
s.mouse_button(108.0, 108.0, "LeftButton", false);
|
||||
|
||||
// A slider the mouse cannot reach ignores a press anywhere — thumb and track alike. This was
|
||||
// `SlTrack:Disable()` until the Button-only `Enable`/`Disable`/`IsEnabled` trio came off the
|
||||
// Slider table (1.12 registers them on `0x879d00` alone, and the Slider's LoadXML has no
|
||||
// `enabled` attribute either). `EnableMouse(false)` is how the reference keeps a slider off a
|
||||
// press, and it gates upstream in the hit test rather than inside `begin_drag`.
|
||||
// A mouse-disabled slider ignores a press on thumb and track alike. A 1.12 Slider has no
|
||||
// `Disable`: the trio is registered on the Button table (`0x879d00`) alone.
|
||||
s.run(r#"SlTrack:SetValue(0); SlTrack:EnableMouse(false); fired = {}"#)
|
||||
.unwrap();
|
||||
for y in [192.0, 150.0] {
|
||||
@@ -184,8 +171,8 @@ fn slider_methods_exist_only_on_sliders() {
|
||||
|
||||
#[test]
|
||||
fn slider_default_orientation_is_vertical() {
|
||||
// The load-bearing fact of decision 0250: every scrollbar omits `orientation` and is vertical,
|
||||
// so the CSimpleSlider ctor default is VERTICAL — the opposite of StatusBar's HORIZONTAL.
|
||||
// Every scrollbar omits `orientation` and is vertical, so the CSimpleSlider default is
|
||||
// `VERTICAL`, where StatusBar's is `HORIZONTAL`.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -242,11 +229,8 @@ fn slider_setvalue_fires_onvaluechanged_only_on_change() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// `Enable`/`Disable`/`IsEnabled` are a **Button** trio in 1.12 (table `0x879d00`), and a Slider
|
||||
/// must not answer them: a duck-typing addon that branches on `if widget.IsEnabled then` reads a
|
||||
/// superset as the wrong class (1189/1250 §5). This asserts the removal *and* its control — the
|
||||
/// same three names still present on a Button, so a regression that emptied the Button table would
|
||||
/// fail here rather than pass by accident.
|
||||
/// `Enable`/`Disable`/`IsEnabled` are Button methods in 1.12 (table `0x879d00`), so a Slider must
|
||||
/// not answer them: addons duck-type on `if widget.IsEnabled then`. A Button is the control.
|
||||
#[test]
|
||||
fn a_slider_does_not_answer_the_buttons_enable_trio() {
|
||||
let s = script();
|
||||
@@ -288,11 +272,9 @@ fn slider_get_thumb_texture_returns_a_stable_region() {
|
||||
|
||||
#[test]
|
||||
fn slider_scrollbar_wiring_does_not_recurse() {
|
||||
// The real FauxScrollFrame wiring (UIPanelTemplates.xml): the scrollbar Slider's OnValueChanged
|
||||
// drives the ScrollFrame's SetVerticalScroll, whose OnVerticalScroll drives the Slider's
|
||||
// SetValue back the other way. A fire-always SetValue would recurse forever; the change-gate
|
||||
// (SliderState::store_value returns Some only on an actual change) breaks the loop after one hop.
|
||||
// This test *is* the recursion — if the gate regressed it would stack-overflow, not assert-fail.
|
||||
// Stock wiring: the slider's OnValueChanged scrolls the frame (`UIPanelTemplates.xml:157`),
|
||||
// whose OnVerticalScroll sets the slider back (`:193`). The change gate
|
||||
// (`SliderState::store_value`) ends it after one hop; without it the stack overflows.
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
@@ -325,12 +307,9 @@ fn slider_scrollbar_wiring_does_not_recurse() {
|
||||
|
||||
#[test]
|
||||
fn slider_value_bits_gate_the_first_fire_and_the_range_reclamp() {
|
||||
// The client's `+0x314` bit1 (has a range) and bit2 (has a value), byte-read off
|
||||
// `SetValue 0x789930` / `SetMinMaxValues 0x7898f0`:
|
||||
// rangeless SetValue is a no-op; the FIRST SetValue always fires, even at the zero-init value;
|
||||
// SetMinMaxValues re-clamps through SetValue only once a value exists. Decision 2095 — Atlas's
|
||||
// option sliders raised from their own <OnLoad> because our SetMinMaxValues clamped the
|
||||
// zero-init value into (0.25, 1) and ran a handler the addon had not armed yet.
|
||||
// The reference's `+0x314` bit1 (has a range) and bit2 (has a value), from `SetValue`
|
||||
// (`0x789930`) and `SetMinMaxValues` (`0x7898f0`): a rangeless SetValue is a no-op, the
|
||||
// first SetValue always fires, and SetMinMaxValues re-clamps only once a value exists.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -364,10 +343,8 @@ fn slider_value_bits_gate_the_first_fire_and_the_range_reclamp() {
|
||||
|
||||
#[test]
|
||||
fn slider_onload_range_does_not_run_an_unarmed_onvaluechanged() {
|
||||
// Atlas's exact shape (AtlasOptions.xml): the slider's <OnLoad> sets a range whose low end is
|
||||
// above zero, and its <OnValueChanged> indexes a saved-variables global that only exists after
|
||||
// ADDON_LOADED. In the reference the load raises nothing; before 2095 ours raised
|
||||
// "attempt to index global 'AtlasOptions' (a nil value)" out of the OnLoad.
|
||||
// Atlas's options slider (`AtlasOptions.xml`): its `<OnValueChanged>` reads a saved variable
|
||||
// that exists only after ADDON_LOADED; in the reference the `<OnLoad>` range raises nothing.
|
||||
let mut s = script();
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
@@ -398,14 +375,10 @@ fn slider_onload_range_does_not_run_an_unarmed_onvaluechanged() {
|
||||
|
||||
#[test]
|
||||
fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
|
||||
// Dewdrop-2.0's `OpenSlider` (Cartographer 2.02 → Look 'n' Feel → Overlay transparency), in
|
||||
// its own construction order — the shape that has no `<ThumbTexture><Size>` anywhere:
|
||||
// `SetThumbTexture(path)` from Lua and nothing else. The reference asks the thumb for its own
|
||||
// `GetWidth`/`GetHeight` (`0x789ba0` → `[thumb_vt+0x1c]/[+0x20]` = `CSimpleTexture::0x770720`
|
||||
// /`0x770790`), which falls back to the art's native texel span — 32×32 for
|
||||
// `UI-SliderBar-Button-Vertical` — so the thumb is a 32-unit knob on a 128-unit track with
|
||||
// 96 units of travel. Ours read the *authored* size only and, finding none, sized the thumb to
|
||||
// the whole track: zero travel, a thumb smeared over the bar, and a drag that cannot move.
|
||||
// Dewdrop-2.0's `OpenSlider`, in its construction order: `SetThumbTexture(path)` and no size.
|
||||
// The reference sizes the thumb by its own `GetWidth`/`GetHeight` (`0x789ba0` calls
|
||||
// `0x770720`, `0x770790`), which fall back to the art's texel span: 32×32 for
|
||||
// `UI-SliderBar-Button-Vertical`, leaving 96 units of travel on the 128-unit track.
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_texture_size_probe(Box::new(|p| {
|
||||
@@ -449,7 +422,7 @@ fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
|
||||
"a sizeless thumb is its art's 32×32, flush at the track top — not the whole 16×128 track"
|
||||
);
|
||||
|
||||
// And it drags: grab the thumb at its centre and pull to the bottom of the track.
|
||||
// Grab the thumb at its centre and pull to the bottom of the track.
|
||||
s.mouse_button(123.0, 233.0, "LeftButton", true);
|
||||
s.mouse_move(123.0, 137.0);
|
||||
let v: f32 = s.eval("return DdSlider:GetValue()").unwrap();
|
||||
@@ -461,10 +434,8 @@ fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
|
||||
|
||||
#[test]
|
||||
fn slider_setvalue_quantises_onto_the_min_anchored_lattice() {
|
||||
// `SetValue 0x789930` rounds the clamped value onto `min + n·step` (round-half-away-from-zero,
|
||||
// truncated by `__ftol`) BEFORE it stores — so `GetValue` on a stepped slider can only ever
|
||||
// answer a lattice point, and the lattice is anchored at `min`, not at zero. Decision 2095
|
||||
// named this divergence and left it; this is it closed.
|
||||
// `SetValue` (`0x789930`) rounds the clamped value onto `min + n·step` (half away from zero,
|
||||
// then `__ftol`) before storing, so the lattice is anchored at `min`, not at zero.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -474,8 +445,7 @@ fn slider_setvalue_quantises_onto_the_min_anchored_lattice() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The lattice is 0.2 / 0.7 / 1.2 — 0.5 and 1.0 are NOT on it, which is the whole point of
|
||||
// anchoring at `min`.
|
||||
// The lattice is 0.2, 0.7, 1.2; 0.5 and 1.0 are not on it.
|
||||
for (set, want) in [
|
||||
(0.6, 0.7),
|
||||
(0.95, 0.7),
|
||||
@@ -495,9 +465,8 @@ fn slider_setvalue_quantises_onto_the_min_anchored_lattice() {
|
||||
|
||||
#[test]
|
||||
fn a_zero_step_leaves_the_value_continuous() {
|
||||
// The gate that keeps every scrollbar working: `UIPanelScrollBarTemplate` declares no
|
||||
// `valueStep`, so `[+0x324]` stays at the ctor's 0.0 and `0x78999c`'s `fcomp(step, 0.0)`
|
||||
// skips the quantiser outright. A stepless slider stores exactly what it was handed.
|
||||
// `UIPanelScrollBarTemplate` declares no `valueStep`, so `[+0x324]` stays at the constructor's
|
||||
// 0.0 and `0x78999c` skips the quantiser: a stepless slider stores what it is handed.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -515,11 +484,9 @@ fn a_zero_step_leaves_the_value_continuous() {
|
||||
|
||||
#[test]
|
||||
fn the_quantised_value_is_not_re_clamped_and_may_pass_max() {
|
||||
// The clamp runs BEFORE the quantiser and there is no second clamp after it (`0x789a06`
|
||||
// stores whatever `fild n; fmul step; fadd min` produced). So a range that is not a whole
|
||||
// number of steps rounds *past* its own max at the top of the travel. This looks like a bug
|
||||
// and is the client's arithmetic; it is unreachable in the shipped UI because every stepped
|
||||
// slider the reference ships is an exact multiple of its step.
|
||||
// The clamp runs before the quantiser and nothing clamps after it (`0x789a06` stores
|
||||
// `min + n·step` as computed), so a range that is not a whole number of steps rounds past
|
||||
// its max. This looks like a bug but is the reference's arithmetic; no stock slider hits it.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -542,11 +509,9 @@ fn the_quantised_value_is_not_re_clamped_and_may_pass_max() {
|
||||
|
||||
#[test]
|
||||
fn a_faux_scrollframes_rows_snap_and_its_bottom_is_exact() {
|
||||
// The falsifier decision 2095 named for this change, run on the shape it names.
|
||||
// `FauxScrollFrame_Update` builds its range as `(numItems − numToDisplay) · valueStep`
|
||||
// (`UIPanelTemplates.lua:180`) and then sets that same step, so the range IS a whole number
|
||||
// of steps: every drag lands on a row boundary and the bottom of the travel is exactly max.
|
||||
// 20 items, 10 on screen, 16 units a row ⇒ range 0..160, step 16.
|
||||
// `FauxScrollFrame_Update` sets the range `(numItems − numToDisplay) · valueStep` and that
|
||||
// same step (`UIPanelTemplates.lua:180`), so every drag lands on a row and the bottom is
|
||||
// exactly max. 20 items, 10 on screen, 16 units a row: range 0..160, step 16.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -560,13 +525,13 @@ fn a_faux_scrollframes_rows_snap_and_its_bottom_is_exact() {
|
||||
(23.9, 16.0), // just under the half-step boundary: the row below
|
||||
(24.1, 32.0), // just over it: the row above
|
||||
(37.0, 32.0),
|
||||
(160.0, 160.0), // the bottom is exact — no overshoot, the range is 10 steps
|
||||
(160.0, 160.0), // the bottom is exact: the range is 10 steps
|
||||
(0.0, 0.0),
|
||||
] {
|
||||
s.run(&format!("SlFaux:SetValue({set})")).unwrap();
|
||||
let v: f32 = s.eval("return SlFaux:GetValue()").unwrap();
|
||||
assert_eq!(v, want, "SetValue({set}) snaps to the row at {want}");
|
||||
// FauxScrollFrame_GetOffset's own arithmetic, which is what the row list reads.
|
||||
// The row offset; stock rounds it (`UIPanelTemplates.lua:231`), equal on a snapped value.
|
||||
let off: f32 = s.eval("return floor(SlFaux:GetValue() / 16)").unwrap();
|
||||
assert_eq!(off, (want / 16.0).floor(), "and the row offset follows it");
|
||||
}
|
||||
@@ -574,10 +539,8 @@ fn a_faux_scrollframes_rows_snap_and_its_bottom_is_exact() {
|
||||
|
||||
#[test]
|
||||
fn a_move_inside_one_step_fires_nothing() {
|
||||
// The quantiser sits INSIDE the change-gate (`0x789a0b`'s `fcom` is against the stored value,
|
||||
// and what is stored is already quantised), so a drag that stays within one step's band
|
||||
// resolves to the same lattice point and never reaches `OnValueChanged`. Before this, every
|
||||
// pixel of a stepped drag fired.
|
||||
// The change gate compares against the stored, already quantised value (`0x789a0b`), so a
|
||||
// drag within one step's band never reaches `OnValueChanged`.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -609,9 +572,8 @@ fn a_move_inside_one_step_fires_nothing() {
|
||||
|
||||
#[test]
|
||||
fn set_value_step_re_quantises_the_held_value_and_can_fire() {
|
||||
// `SetValueStep 0x789a60` stores the step and then re-pushes the range through
|
||||
// `SetMinMaxValues`, which re-clamps the held value through `SetValue` — onto the NEW lattice
|
||||
// (decision 2143). A step handed to a slider that already holds a value moves it and fires.
|
||||
// `SetValueStep` (`0x789a60`) stores the step and re-pushes the range, which re-clamps a held
|
||||
// value through `SetValue` onto the new lattice, and that move fires.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -626,7 +588,7 @@ fn set_value_step_re_quantises_the_held_value_and_can_fire() {
|
||||
assert_eq!(s.eval::<f32>("return SlStep:GetValue()").unwrap(), 37.0);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
|
||||
// A step arriving AFTER the value snaps it: 37 → 40 on a 10-lattice, and the move fires.
|
||||
// A step arriving after the value snaps it: 37 → 40 on a 10-lattice.
|
||||
s.run("SlStep:SetValueStep(10)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SlStep:GetValue()").unwrap(),
|
||||
@@ -639,7 +601,6 @@ fn set_value_step_re_quantises_the_held_value_and_can_fire() {
|
||||
"…and the move fired OnValueChanged, exactly as a SetValue would"
|
||||
);
|
||||
|
||||
// A step that moves nothing fires nothing — the same change gate, one level up.
|
||||
s.run("SlStep:SetValueStep(20)").unwrap();
|
||||
assert_eq!(s.eval::<f32>("return SlStep:GetValue()").unwrap(), 40.0);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
@@ -647,10 +608,8 @@ fn set_value_step_re_quantises_the_held_value_and_can_fire() {
|
||||
|
||||
#[test]
|
||||
fn a_step_before_any_range_is_the_whole_call() {
|
||||
// `0x789a6c test byte [+0x314],2` — bit1 clear ⇒ the step is stored and the function returns.
|
||||
// No range is pushed, nothing is clamped, nothing fires. It is what keeps an `<OnLoad>` that
|
||||
// sets a step before a range from running a handler the addon has not armed (2095's subject,
|
||||
// one verb over).
|
||||
// With no range (bit1 clear at `0x789a6c`) the step is stored and nothing else happens, so an
|
||||
// `<OnLoad>` that sets a step before a range runs no handler.
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! StatusBar (per-kind behavior; LoadXML `0x782ef0`).
|
||||
//! StatusBar behaviour (`LoadXML` `0x782ef0`).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
@@ -126,9 +126,8 @@ fn statusbar_bar_region_scales_by_fraction_on_extract() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The fill CROPs its art, never squeezes it (`0x770410`): `SetValue`
|
||||
/// rewrites the region's 4-corner UV block with `u1 = GetValue()` *and* shrinks the quad. Squeezing
|
||||
/// instead would run a bar texture's whole horizontal ramp inside every partial fill.
|
||||
/// The fill crops its art, never squeezes it (`0x770410`): `SetValue` sets the UV `u1` to the fill
|
||||
/// fraction `(value - min) / (max - min)` and shrinks the quad by the same fraction.
|
||||
#[test]
|
||||
fn statusbar_bar_region_crops_its_texture_rather_than_stretching_it() {
|
||||
let mut s = script();
|
||||
@@ -164,7 +163,7 @@ fn statusbar_bar_region_crops_its_texture_rather_than_stretching_it() {
|
||||
"horizontal: u cropped to the fill fraction, v whole"
|
||||
);
|
||||
|
||||
// Vertical fills bottom-up, so the art's BOTTOM edge is the one pinned.
|
||||
// Vertical fills bottom-up, so the art's bottom edge is pinned.
|
||||
s.run(r#"SbCrop:SetOrientation("VERTICAL")"#).unwrap();
|
||||
assert_eq!(
|
||||
uv(&s),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! The WoW stdlib: positional `format`, the `getglobal` alias layer, and the
|
||||
//! sandbox holes (`loadstring` text-only, dangerous globals removed).
|
||||
//! The WoW stdlib: positional `format`, the bare-global aliases, the sandbox, `debugstack`,
|
||||
//! `GetTime`, `time`/`date`, `RunScript` and the Lua 5.0 dialect.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
// ── The positional format wrapper ───────────────────────────────────────────────────────────────
|
||||
// ── The positional format wrapper ──
|
||||
|
||||
#[test]
|
||||
fn positional_format_reorders_and_mix_is_an_error() {
|
||||
@@ -18,7 +18,7 @@ fn positional_format_reorders_and_mix_is_an_error() {
|
||||
s.eval::<String>(r#"return format("%1$05d", 42)"#).unwrap(),
|
||||
"00042"
|
||||
);
|
||||
// sequential still works (and via string.format too, which we patched)
|
||||
// sequential still works, through the patched `string.format` too
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return string.format("%d-%s", 1, "x")"#)
|
||||
.unwrap(),
|
||||
@@ -29,14 +29,14 @@ fn positional_format_reorders_and_mix_is_an_error() {
|
||||
s.eval::<String>(r#"return format("%1$d%%", 50)"#).unwrap(),
|
||||
"50%"
|
||||
);
|
||||
// mixing positional and sequential is an error (matches Blizzard erroring)
|
||||
// mixing positional and sequential is an error, as in the reference
|
||||
let mixed_ok: bool = s
|
||||
.eval(r#"return pcall(format, "%1$s %s", "a", "b")"#)
|
||||
.unwrap();
|
||||
assert!(!mixed_ok, "mixed positional+sequential must error");
|
||||
}
|
||||
|
||||
// ── getglobal / the alias layer ───────────────────────────────────────────────
|
||||
// ── getglobal and the alias layer ──
|
||||
|
||||
#[test]
|
||||
fn stdlib_aliases_and_helpers() {
|
||||
@@ -65,7 +65,7 @@ fn stdlib_aliases_and_helpers() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Sandbox holes ───────────────────────────────────────────────────────────────────────────────
|
||||
// ── Sandbox holes ──
|
||||
|
||||
#[test]
|
||||
fn sandbox_removes_dangerous_globals() {
|
||||
@@ -77,39 +77,24 @@ fn sandbox_removes_dangerous_globals() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(all_nil);
|
||||
// `debugstack` survives the sandbox — and returns a REAL traceback, not the `""` this used to
|
||||
// assert. The stub was fine for addons that only DISPLAY it and wrong for the ones that PARSE
|
||||
// it: `FuBarPlugin-2.0.lua:752` finds each plugin's own folder in `debugstack(6, 1, 0)`, and
|
||||
// against `""` that returned nil and killed 20 corpus addons.
|
||||
// `debugstack` survives the sandbox and returns a real traceback, which addons parse.
|
||||
let trace = s.eval::<String>("return debugstack()").unwrap();
|
||||
assert!(
|
||||
!trace.is_empty(),
|
||||
"debugstack must return a real traceback: {trace:?}"
|
||||
);
|
||||
// **Frames only — no `stack traceback:` header** (decision 2121). Line 1 IS a frame, which is
|
||||
// what `AceLibrary.lua:70` reads (`string.gsub(stack, "\n.*", "")` then
|
||||
// `".*\\(.*).lua:%d+: .*"`), and what `AceDB-2.0.lua:742` counts on when it skips exactly one
|
||||
// line to reach its caller's.
|
||||
// Frames only, no `stack traceback:` header: `AceLibrary.lua:70` reads line 1 as a frame and
|
||||
// `AceDB-2.0.lua:742` skips exactly one line to reach its caller.
|
||||
assert!(
|
||||
!trace.starts_with("stack traceback"),
|
||||
"the reference has no header line: {trace:?}"
|
||||
);
|
||||
// A level far past the top of the stack is an empty string, never a raise — a caller that
|
||||
// guesses too deep still gets something it can `string.find` against.
|
||||
// A level past the top of the stack is an empty string, never a raise.
|
||||
assert_eq!(s.eval::<String>("return debugstack(99)").unwrap(), "");
|
||||
}
|
||||
|
||||
/// **AceDB-2.0's own capture, run for real across two chunks** (decision 2121).
|
||||
///
|
||||
/// `RegisterDB` reads the calling addon's folder out of `debugstack()` by skipping exactly one
|
||||
/// line — its own frame — and taking the `\AddOns\<folder>\` out of the next. That only works if
|
||||
/// line 1 is a frame; with mlua's `stack traceback:` header in front, the capture returned the
|
||||
/// folder of whichever addon shipped the winning copy of the library, `RegisterDB` took its
|
||||
/// already-loaded branch, and `db.raw` was bound to a fresh table at the addon's file scope —
|
||||
/// before the SavedVariables chunk ran. Bartender2 then printed `Creating new DB` at every login.
|
||||
///
|
||||
/// The two chunks here are the real configuration: the library lives in one addon's folder, the
|
||||
/// caller in another's.
|
||||
/// AceDB-2.0's `RegisterDB` skips one line of `debugstack()`, its own frame, and takes the caller's
|
||||
/// `\AddOns\<folder>\` from the next; here the library and its caller load from different addons.
|
||||
#[test]
|
||||
fn acedbs_capture_names_the_calling_addon_not_the_librarys_owner() {
|
||||
let s = script();
|
||||
@@ -132,7 +117,7 @@ fn acedbs_capture_names_the_calling_addon_not_the_librarys_owner() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `AceLibrary.lua:70`'s reader: the first line, whole, is a frame it can pull a file name out of.
|
||||
/// `AceLibrary.lua:70` pulls a file name out of the first line, a whole frame.
|
||||
#[test]
|
||||
fn the_first_debugstack_line_is_a_frame() {
|
||||
let s = script();
|
||||
@@ -151,9 +136,8 @@ fn the_first_debugstack_line_is_a_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `AceLibrary.lua:139`'s `argCheck` names the offending function with `"([`<].-['>])"` — a
|
||||
/// BACKTICK opening it. Lua 5.4 writes `in function 'name'`, which that pattern cannot match; the
|
||||
/// reference's 5.0 wording is `` in function `name' ``.
|
||||
/// The reference's Lua 5.0 writes a named frame as `` in function `name' ``, opening with a
|
||||
/// backtick, which `AceLibrary.lua:139`'s `argCheck` pattern `` "([`<].-['>])" `` needs.
|
||||
#[test]
|
||||
fn a_named_frame_uses_the_5_0_backtick_quoting() {
|
||||
let s = script();
|
||||
@@ -165,18 +149,10 @@ fn a_named_frame_uses_the_5_0_backtick_quoting() {
|
||||
assert_eq!(found, "`BenillaProbeNamed'");
|
||||
}
|
||||
|
||||
/// **Every frame carries its own trailing `\n`, and `count1` bounds nothing on its own**
|
||||
/// (decision 2121).
|
||||
///
|
||||
/// Stock Lua 5.0 pushes `"\n\t"` *before* each frame plus a header once; the reference pushes a
|
||||
/// single `"\n"` after each frame and no header (`0x703971`). And the walk formats while the level
|
||||
/// is `<= start + count1` — `0x703857` is `jbe`, unsigned ≤ — then probes
|
||||
/// `getstack(level + count2)`: a probe that FAILS steps back and prints that level as an ordinary
|
||||
/// frame. So with `count1 = 1, count2 = 0` a two-deep stack returns TWO frames and no marker, and a
|
||||
/// three-deep one returns one frame plus `"...\n"` and nothing after. A client that clamped to
|
||||
/// `count1` would diverge at exactly `depth == count1 + 1` — and that is
|
||||
/// `FuBarPlugin-2.0.lua:752`'s `debugstack(6, 1, 0)`, whose capture is a GREEDY
|
||||
/// `"\\AddOns\\(.*)\\"` reading the LAST path in the string.
|
||||
/// The reference ends each frame with `"\n"` and writes no header (`0x703971`). It formats while
|
||||
/// the level is `<= start + count1` (`0x703857`, `jbe`), then probes `getstack(level + count2)`; a
|
||||
/// failed probe prints that level as an ordinary frame. So with `count1 = 1, count2 = 0` a
|
||||
/// two-deep stack gives two frames and no marker, and a three-deep one a frame then `"...\n"`.
|
||||
#[test]
|
||||
fn debugstack_frames_end_in_newline_and_count1_does_not_clamp() {
|
||||
let s = script();
|
||||
@@ -185,8 +161,6 @@ fn debugstack_frames_end_in_newline_and_count1_does_not_clamp() {
|
||||
trace.ends_with('\n'),
|
||||
"the newline is pushed AFTER each frame: {trace:?}"
|
||||
);
|
||||
// Two levels at or above `start`, `count1 = 1`: the elision probe fails, so the second frame
|
||||
// prints in full and no marker appears.
|
||||
let two: String = s
|
||||
.eval(
|
||||
"function BenillaProbeDepth2() local r = debugstack(1, 1, 0) return r end \
|
||||
@@ -196,7 +170,6 @@ fn debugstack_frames_end_in_newline_and_count1_does_not_clamp() {
|
||||
let lines: Vec<&str> = two.trim_end().split('\n').collect();
|
||||
assert_eq!(lines.len(), 2, "two frames, no marker: {two:?}");
|
||||
assert!(!two.contains("..."), "no elision at depth 2: {two:?}");
|
||||
// Deeper: one frame, then the marker on its own line, and nothing after it (count2 = 0).
|
||||
let deep: String = s
|
||||
.eval(
|
||||
"function BenillaProbeC() local r = debugstack(1, 1, 0) return r end \
|
||||
@@ -210,20 +183,15 @@ fn debugstack_frames_end_in_newline_and_count1_does_not_clamp() {
|
||||
assert_eq!(lines[1], "...", "the marker is its own complete line");
|
||||
}
|
||||
|
||||
/// **An addon's chunk is named the way the CLIENT names it**, because addons parse that name.
|
||||
///
|
||||
/// `FuBarPlugin-2.0.lua:752` is `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, and it
|
||||
/// feeds the capture into `format("Interface\\AddOns\\%s\\icon", folderName)`. Without a name
|
||||
/// mlua defaults the chunk to the RUST caller location, that pattern misses, and every FuBar plugin
|
||||
/// dies formatting a nil three frames from the cause.
|
||||
///
|
||||
/// The greedy `(.*)` is why the file has to be in the name too: it captures to the LAST backslash.
|
||||
/// An addon chunk is named as the reference names it, `@Interface\AddOns\<folder>\<file>`:
|
||||
/// `FuBarPlugin-2.0.lua:752` finds its folder with a greedy `"\\AddOns\\(.*)\\"`, which captures
|
||||
/// up to the last backslash, so the file must be in the name.
|
||||
#[test]
|
||||
fn an_addon_chunk_is_named_the_way_the_client_names_it() {
|
||||
let name = crate::script::addon_chunk_name("FuBar_BagFu", "FuBar_BagFu.lua");
|
||||
assert_eq!(name, "@Interface\\AddOns\\FuBar_BagFu\\FuBar_BagFu.lua");
|
||||
|
||||
// FuBar's own pattern, run for real against a traceback from a chunk loaded under that name.
|
||||
// FuBar's own pattern, against a traceback from a chunk loaded under that name.
|
||||
let s = script();
|
||||
s.run_chunk_named(
|
||||
b"function BenillaProbeFolder() return debugstack(1, 1, 0) end",
|
||||
@@ -267,7 +235,7 @@ fn loadstring_is_text_only_bytecode_rejected() {
|
||||
assert!(ok, "loadstring must reject bytecode");
|
||||
}
|
||||
|
||||
// ── GetTime: the session clock (decision 0137 — the reference cast bar anchors on it) ───────────
|
||||
// ── GetTime: the session clock ──
|
||||
|
||||
#[test]
|
||||
fn gettime_starts_at_zero_and_tracks_tick() {
|
||||
@@ -279,15 +247,7 @@ fn gettime_starts_at_zero_and_tracks_tick() {
|
||||
assert!((t - 0.5).abs() < 1e-6, "two 0.25s ticks = 0.5 (got {t})");
|
||||
}
|
||||
|
||||
/// The rest of the bare globals Blizzard's own restricted scope enumerates (decision 1187).
|
||||
///
|
||||
/// The list is not from memory: `Blizzard_RestrictedAddOnEnvironment/RestrictedEnvironment.lua`
|
||||
/// copies exactly these out of the engine, which is what distinguishes them from the table helpers
|
||||
/// (`tContains`, `tInvert`, …) that FrameXML defines in Lua and we must therefore NOT supply.
|
||||
///
|
||||
/// The measured cost of their absence: Bagnon + BagBrother produced 94 load failures, 81 of them
|
||||
/// `Cannot find a library instance of "…"`, because `strmatch` was nil so every LibStub-registering
|
||||
/// library aborted before it could register.
|
||||
/// The rest of the engine's bare string and math globals, and the later-client names it lacks.
|
||||
#[test]
|
||||
fn the_rest_of_the_bare_globals() {
|
||||
let s = script();
|
||||
@@ -318,20 +278,8 @@ fn the_rest_of_the_bare_globals() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **The Lua 5.0 dialect a vanilla addon is written in runs on our 5.1 VM** — measured, because
|
||||
/// this question has been answered three different ways from memory.
|
||||
///
|
||||
/// 1.12 runs Lua 5.0 (byte-confirmed: `0x811b30 = "Lua: Lua 5.0 Copyright..."`);
|
||||
/// we run 5.1 via mlua's `lua51`. Decision 1188 called that "the deepest divergence and it is
|
||||
/// unresolved" and told the next session to test it; 1189 replied that 0068 had already closed it.
|
||||
/// Meanwhile five of our own transcribed FrameXML files carried the opposite claim in a comment —
|
||||
/// *"`LUA_COMPAT_VARARG` isn't shipped"* — each citing the others as precedent, so a false fact
|
||||
/// propagated by citation without anyone re-running it.
|
||||
///
|
||||
/// It is shipped. This asserts the five idioms vanilla addon code actually uses, so the next
|
||||
/// session reads a result instead of a recollection. **The implicit `arg` table is the one that
|
||||
/// matters**: it is the difference between a vanilla addon's vararg functions working and every
|
||||
/// one of them erroring at runtime, which is 1188's stated redirect-the-arc risk.
|
||||
/// 1.12 runs Lua 5.0 (`0x811b30`, "Lua: Lua 5.0 Copyright..."); this VM is mlua's `lua51`, and the
|
||||
/// 5.0 idioms vanilla addons use run on it, above all the implicit vararg `arg` table.
|
||||
#[test]
|
||||
fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
|
||||
let s = script();
|
||||
@@ -364,22 +312,13 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **`time()` is real epoch seconds and `date()` formats them** — engine globals in the 1.12
|
||||
/// client's own `_G` (slots 34/33 of its base registry) that we lacked entirely, because the
|
||||
/// sandbox strips `os`.
|
||||
///
|
||||
/// `time` was the top name in the session-start `attempt to call global` row. Every corpus site
|
||||
/// persists it into SavedVariables and compares across sessions
|
||||
/// (`FTC_Save[k].LastCheck = time()`), so a session-relative clock like `GetTime` would be wrong in
|
||||
/// a way that only shows up on the SECOND login.
|
||||
///
|
||||
/// The formats asserted are the ones read off real call sites, against a FIXED epoch so the
|
||||
/// conversion is checked rather than the machine's clock: 2001-09-09 01:46:40 UTC, a Sunday.
|
||||
/// `time()` is wall-clock epoch seconds and `date()` formats them, both engine globals in the 1.12
|
||||
/// `_G` (slots 34 and 33 of its base registry); addons save `time()` and compare across logins.
|
||||
#[test]
|
||||
fn time_is_epoch_seconds_and_date_formats_them() {
|
||||
let s = script();
|
||||
|
||||
// A plausible wall clock: after 2020, before 2100. Pins that this is not GetTime's 0-based one.
|
||||
// A plausible wall clock, between 2020 and 2100, not GetTime's 0-based one.
|
||||
let now: i64 = s.eval("return time()").unwrap();
|
||||
assert!(
|
||||
(1_577_836_800..4_102_444_800).contains(&now),
|
||||
@@ -409,31 +348,26 @@ fn time_is_epoch_seconds_and_date_formats_them() {
|
||||
assert_eq!(got, want, "date({fmt:?})");
|
||||
}
|
||||
|
||||
// Bare `date()` is `%c` (Recap.lua:2690 calls it with no arguments) and must not raise.
|
||||
// Bare `date()` is `%c` and must not raise; `Recap.lua:2690` calls it with no arguments.
|
||||
let bare: String = s.eval("return date()").unwrap();
|
||||
assert!(
|
||||
bare.len() > 10,
|
||||
"bare date() must format something: {bare:?}"
|
||||
);
|
||||
|
||||
// A leap day, because the civil conversion is where a date implementation goes wrong.
|
||||
// A leap day, the civil conversion's edge case.
|
||||
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
|
||||
assert_eq!(leap, "2000-02-29 Tuesday");
|
||||
}
|
||||
|
||||
// ── RunScript, at the image's own contract (2136's "left open", closed) ──────────────────────────
|
||||
// ── RunScript ──
|
||||
|
||||
/// `RunScript`'s chunk name is the SOURCE, not a label of ours.
|
||||
///
|
||||
/// `0x48b9c7 mov edx,eax` / `0x48b9c9 mov ecx,eax` hand `lua_tostring`'s one return to
|
||||
/// `FrameScript_Execute 0x704cd0` as both the code and the name, so the name reaches
|
||||
/// `luaL_loadbuffer` unprefixed and `luaO_chunkid 0x6f5c40` wraps it — `[string "…"]`. We used to
|
||||
/// write `=[RunScript]`, whose leading `=` is chunkid's *print this verbatim* marker.
|
||||
/// `RunScript` hands its string to `FrameScript_Execute` (`0x704cd0`) as both code and chunk name
|
||||
/// (`0x48b9c7`, `0x48b9c9`), so `luaO_chunkid` (`0x6f5c40`) reports it as `[string "…"]`.
|
||||
#[test]
|
||||
fn a_runscript_chunk_is_named_by_its_own_source() {
|
||||
let mut s = script();
|
||||
// The error is consumed by RunScript (fact 3), so read it off the recorded channel rather
|
||||
// than off a raise.
|
||||
// RunScript consumes the error, so read it off the recorded channel.
|
||||
s.run("RunScript(\"error('boom')\")").unwrap();
|
||||
let errs = s.take_errors();
|
||||
assert!(
|
||||
@@ -443,15 +377,12 @@ fn a_runscript_chunk_is_named_by_its_own_source() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The three silent legs, and the raise that must NOT happen.
|
||||
///
|
||||
/// `lua_isstring 0x6f3510` is tag-based, so a number passes and runs as its own text; every other
|
||||
/// type takes `0x48b98f je 0x48b9f3` to `xor eax,eax; ret`. An empty string takes the same exit at
|
||||
/// `0x48b9a1`. There is no `luaL_error 0x6f4940` in the function at all.
|
||||
/// A non-string argument returns silently (`0x48b98f` jumps to `0x48b9f3`), as does an empty
|
||||
/// string (`0x48b9a1`); a number passes `lua_isstring` (`0x6f3510`) and runs as text. The function
|
||||
/// never calls `luaL_error` (`0x6f4940`).
|
||||
#[test]
|
||||
fn runscript_swallows_a_bad_argument_instead_of_raising() {
|
||||
let mut s = script();
|
||||
// Each of these must return normally AND leave the next statement running.
|
||||
s.run(
|
||||
"BenillaRan = 0
|
||||
RunScript(nil)
|
||||
@@ -466,7 +397,7 @@ fn runscript_swallows_a_bad_argument_instead_of_raising() {
|
||||
s.take_errors().is_empty(),
|
||||
"a silent no-op records nothing either"
|
||||
);
|
||||
// A number IS a string to `lua_isstring`, so it compiles — and `42` is not a statement.
|
||||
// A number is a string to `lua_isstring`, so it compiles, and `42` is not a statement.
|
||||
s.run("RunScript(42)").unwrap();
|
||||
assert!(
|
||||
s.take_errors()
|
||||
@@ -476,11 +407,9 @@ fn runscript_swallows_a_bad_argument_instead_of_raising() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A raise inside the snippet does not escape it: `0x704ae0` runs the chunk under
|
||||
/// `lua_pcall(L, 0, 0, -2)` (`0x704b68`) with the registry's error handler pushed at `0x704afe`,
|
||||
/// and pcalls that same handler for a *compile* failure (`0x704b42`). Both legs return 0 values.
|
||||
///
|
||||
/// This is the one with teeth: raising here let one bad macro abort whatever ran it.
|
||||
/// A raise inside the snippet stays there: `0x704ae0` runs it under `lua_pcall` (`0x704b68`) with
|
||||
/// the registry's error handler (`0x704afe`), and calls that handler for a compile failure too
|
||||
/// (`0x704b42`).
|
||||
#[test]
|
||||
fn a_runscript_error_reaches_the_handler_and_not_the_caller() {
|
||||
let mut s = script();
|
||||
@@ -503,7 +432,7 @@ fn a_runscript_error_reaches_the_handler_and_not_the_caller() {
|
||||
"both errors are recorded, not dropped: {errs:?}"
|
||||
);
|
||||
assert!(errs[0].contains("inner"), "{errs:?}");
|
||||
// mlua's `Display` category word ("syntax error: ") is not something the image ever writes.
|
||||
// mlua's `Display` prefix ("syntax error: ") is not something the reference writes.
|
||||
assert!(
|
||||
errs[1].starts_with("[string \"this is not lua\"]:1:"),
|
||||
"a compile failure is reported under the same name, undecorated: {errs:?}"
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
//! **`GetChildren` / `GetNumChildren` / `GetRegions` / `GetNumRegions`** — the four structure
|
||||
//! queries, and the properties an addon walking a frame it did not build depends on.
|
||||
//!
|
||||
//! The consumer that motivated them is `pfUI.api.StripTextures`, embedded in four separate top-20
|
||||
//! addons (pfUI, pfQuest, pfQuest-turtle, ShaguDPS):
|
||||
//!
|
||||
//! ```lua
|
||||
//! for _, v in ipairs({ frame:GetRegions() }) do
|
||||
//! if v.SetTexture then ... end
|
||||
//! end
|
||||
//! ```
|
||||
//!
|
||||
//! so the shape under test is the one that idiom needs: **multiple return values**, in the
|
||||
//! structure's own order, each a usable widget object.
|
||||
//!
|
||||
//! Every claim here is confirmed against the reference (`0x773f60`/`0x774180`): the lists are
|
||||
//! `[frame+0x300]` and `[frame+0x1b8]`, both linkers APPEND AT THE TAIL so the values come back
|
||||
//! oldest-first with no reversal, hidden nodes
|
||||
//! are returned and counted, a detached region and the title region are both absent, a Button's
|
||||
//! label and state textures are present, and an empty frame returns zero values while `GetNum*`
|
||||
//! returns the number `0`.
|
||||
//! The structure queries `GetChildren`, `GetNumChildren`, `GetRegions` and `GetNumRegions`
|
||||
//! (`0x773f60`/`0x774180`): they walk `[frame+0x300]` and `[frame+0x1b8]`, whose linkers append at
|
||||
//! the tail, so values come back oldest first as multiple returns, hidden ones included.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// The whole contract in one document: both lists, their order, the counts agreeing with them, the
|
||||
/// empty case, and — the one that is a behaviour rather than a plumbing detail — that a region
|
||||
/// detached by `SetParent(nil)` leaves both region verbs.
|
||||
#[test]
|
||||
fn the_structure_queries_report_the_structure() {
|
||||
let mut s = script();
|
||||
@@ -48,8 +27,7 @@ fn the_structure_queries_report_the_structure() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// ── The lists, by name and IN ORDER. Named rather than counted, so a walk that returned the
|
||||
// right number of the wrong things (or the right things reversed) cannot pass.
|
||||
// ── The lists, by name and in order.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local n = '' for _, v in ipairs({ SQHost:GetChildren() }) do \
|
||||
@@ -69,9 +47,7 @@ fn the_structure_queries_report_the_structure() {
|
||||
"regions come back in CREATION order, not draw order — the OVERLAY fontstring stays second"
|
||||
);
|
||||
|
||||
// ── The counts are the same walk. A count that disagrees with the list is an off-by-one deep
|
||||
// inside a loop the addon did not write, so the two verbs share one function by construction
|
||||
// and this pins it.
|
||||
// ── The counts are the same walk.
|
||||
assert_eq!(
|
||||
s.eval::<(usize, usize)>(
|
||||
"return SQHost:GetNumChildren(), table.getn({ SQHost:GetChildren() })"
|
||||
@@ -87,8 +63,7 @@ fn the_structure_queries_report_the_structure() {
|
||||
(3, 3)
|
||||
);
|
||||
|
||||
// ── The objects are USABLE, not opaque handles: `StripTextures` feature-tests `v.SetTexture`
|
||||
// to tell a texture from a fontstring, then calls it. Both halves have to hold.
|
||||
// ── Usable objects: pfUI's `StripTextures` tests `v.SetTexture`, then calls it.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local out = '' for _, v in ipairs({ SQHost:GetRegions() }) do \
|
||||
@@ -100,8 +75,7 @@ fn the_structure_queries_report_the_structure() {
|
||||
"a texture answers SetTexture and a fontstring does not — the StripTextures branch"
|
||||
);
|
||||
|
||||
// ── A childless, regionless frame yields NOTHING, and counts zero. `ipairs` over it must run
|
||||
// zero times rather than once over a nil.
|
||||
// ── An empty frame returns no values and counts zero.
|
||||
assert_eq!(
|
||||
s.eval::<(usize, usize, usize)>(
|
||||
"local e = CreateFrame(\"Frame\", \"SQEmpty\", UIParent) \
|
||||
@@ -111,10 +85,8 @@ fn the_structure_queries_report_the_structure() {
|
||||
(0, 0, 0)
|
||||
);
|
||||
|
||||
// ── DETACHED REGIONS LEAVE BOTH VERBS. `Region:SetParent(nil)` unlinks from the parent's draw
|
||||
// layer AND its region list in the client (`0x77fd10`). We keep the entry so the arena can
|
||||
// still free the slot — a representation choice that must not be observable here, or a
|
||||
// StripTextures-shaped walk would "strip" a region that is already off the screen.
|
||||
// ── A detached region leaves both verbs: `Region:SetParent(nil)` unlinks it from the draw
|
||||
// layer and the region list (`0x77fd10`); our arena keeps the entry, unseen here.
|
||||
s.run("SQFontB:SetParent(nil)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
@@ -131,12 +103,8 @@ fn the_structure_queries_report_the_structure() {
|
||||
"...and from the count, which is the same walk"
|
||||
);
|
||||
|
||||
// ── THE TITLE REGION IS NOT IN THE LIST, and this is the assertion that corrected us. Its two
|
||||
// creation paths in the client dispatch a vtable slot that is a bare `[this+0x9c] = parent`
|
||||
// and never reach the region linker, so it was never in the list `GetRegions` walks —
|
||||
// corroborated by `Hide`/`Show` carrying an explicit extra `[frame+0xa8]` case *because* the
|
||||
// walk misses it (`0x768060`). Ours lives in `Frame::regions` so the arena can still free
|
||||
// it, which makes that a representation detail this walk must not leak.
|
||||
// ── The title region is not in the list: its creation sets `[this+0x9c] = parent` without
|
||||
// the region linker, and `Hide`/`Show` handle `[frame+0xa8]` separately (`0x768060`).
|
||||
s.run("SQHost:CreateTitleRegion()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return SQHost:GetNumRegions()").unwrap(),
|
||||
@@ -153,8 +121,7 @@ fn the_structure_queries_report_the_structure() {
|
||||
"...and does not appear in it"
|
||||
);
|
||||
|
||||
// ── A child reparented away leaves its old parent's child list too (the frame twin of the
|
||||
// above, and the case an addon hits when it re-hosts a default-UI frame).
|
||||
// ── A reparented child leaves its old parent's list.
|
||||
s.run("SQChildB:SetParent(UIParent)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The talent engine seam (decision 0304): the Era binding tuples over a pushed snapshot, the
|
||||
//! learn-click queue, and the `SetTalent` tooltip — the spell builder with the talent
|
||||
//! interleave (rank line white, req lines red, next-rank block, learn hint green).
|
||||
//! The talent bindings over a pushed snapshot, the learn queue, and `SetTalent`: the spell tooltip
|
||||
//! with the talent lines interleaved.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
@@ -62,8 +61,6 @@ fn one_tab_state() -> TalentUiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Era tuples read back exactly what the app pushed — including the 1-based grid seats,
|
||||
/// the flat prereq triplets, and the points pair.
|
||||
#[test]
|
||||
fn bindings_read_the_pushed_snapshot() {
|
||||
let mut s = script();
|
||||
@@ -95,7 +92,6 @@ fn bindings_read_the_pushed_snapshot() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// LearnTalent queues (tab, index) verbatim for the app's wire drain.
|
||||
#[test]
|
||||
fn learn_talent_queues_for_the_app_drain() {
|
||||
let mut s = script();
|
||||
@@ -106,9 +102,8 @@ fn learn_talent_queues_for_the_app_drain() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A stand-in string table for the talent tail's three keys — **deliberately not the shipped
|
||||
/// wording**, because what is under test is which key each line reaches and what fills it, never
|
||||
/// what the sentence says (decision 2045).
|
||||
/// Stand-in values for the three talent keys, not the shipped wording: the tests check which key
|
||||
/// each line reaches.
|
||||
fn seed_talent_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -120,9 +115,8 @@ fn seed_talent_strings(s: &mut UiScript) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// SetTalent = the spell builder + the talent interleave: name, TOOLTIP_TALENT_RANK white, cost
|
||||
/// line, gold description, TOOLTIP_TALENT_NEXT_RANK + the next rank's gold description, green
|
||||
/// TOOLTIP_TALENT_LEARN.
|
||||
/// `SetTalent`'s lines: name, `TOOLTIP_TALENT_RANK` (white), cost, gold description,
|
||||
/// `TOOLTIP_TALENT_NEXT_RANK` and the next rank's gold description, green `TOOLTIP_TALENT_LEARN`.
|
||||
#[test]
|
||||
fn set_talent_renders_the_interleaved_tooltip() {
|
||||
let mut s = script();
|
||||
@@ -162,7 +156,6 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The learn hint wears the tooltip green; the requirement red is exercised below.
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let green = quads.iter().any(|q| {
|
||||
@@ -173,14 +166,13 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A locked talent shows its red requirement line; a missing spell view falls back to the rank
|
||||
/// line alone and records the ask for the app resolver (the shared ask-once channel).
|
||||
/// A missing spell view renders the rank line alone and asks for the view once.
|
||||
#[test]
|
||||
fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
let mut s = script();
|
||||
seed_talent_strings(&mut s);
|
||||
s.set_talents(one_tab_state());
|
||||
// No spell view pushed for Ignite (11119): the render falls back, the ask is recorded.
|
||||
// No spell view pushed for Ignite (11119).
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "TB2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
|
||||
@@ -198,7 +190,6 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
"the display spell was asked: {asks:?}"
|
||||
);
|
||||
|
||||
// With the view landed, the full render carries the red requirement line.
|
||||
s.set_spell_tooltip(
|
||||
11119,
|
||||
SpellTooltipView {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
//! The taxi seam (decision 0484): the app-pushed snapshot surfaces through the Era bindings the
|
||||
//! reference `TaxiFrame.lua` reads, and the click/close intents drain back out.
|
||||
//! The taxi bindings stock `TaxiFrame.lua` reads over a pushed snapshot, and the node and close
|
||||
//! intents.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// The full round trip: push a two-node snapshot, read it back exactly as the reference Lua does
|
||||
/// (count, type, position, name, cost, route segments), click a node + close, drain both intents,
|
||||
/// and `SetTaxiMap` paints the pushed art onto the target texture region.
|
||||
#[test]
|
||||
fn taxi_snapshot_surfaces_and_intents_drain() {
|
||||
let mut s = script();
|
||||
@@ -62,7 +59,6 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
|
||||
assert!(s.take_taxi_close());
|
||||
assert!(!s.take_taxi_close(), "the close flag drains");
|
||||
|
||||
// SetTaxiMap assigned the pushed art onto the texture region.
|
||||
s.resolve();
|
||||
assert!(
|
||||
s.extract().iter().any(|q| matches!(
|
||||
@@ -72,7 +68,6 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
|
||||
"the continent art draws on the SetTaxiMap target"
|
||||
);
|
||||
|
||||
// The ride flag reaches UnitOnTaxi.
|
||||
s.set_on_taxi(true);
|
||||
s.run(r#"assert(UnitOnTaxi("player") == 1)"#).unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
//! TexCoords + Font objects (decision 0084).
|
||||
//! `SetTexCoord` and font objects.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// `SetTexCoord(l, r, t, b)` writes the region's UV sub-rect; it surfaces on the extracted quad,
|
||||
/// `GetTexCoord` reads it back; the 8-arg affine form carries per-corner UVs (the route-line
|
||||
/// draw's rotated quads).
|
||||
#[test]
|
||||
fn set_tex_coord_changes_extracted_uv() {
|
||||
let mut s = script();
|
||||
@@ -32,14 +29,12 @@ fn set_tex_coord_changes_extracted_uv() {
|
||||
tex.content
|
||||
);
|
||||
|
||||
// EIGHT values, per corner, in `SetTexCoord`'s own usage order — `ULx, ULy, LLx, LLy, URx,
|
||||
// URy, LRx, LRy` (decision 1840). A Rect stored as `[l, r, t, b]` comes back out as its four
|
||||
// corners, which is the only shape the reference has: there is no 4-value getter.
|
||||
// `GetTexCoord` returns eight values in `SetTexCoord`'s corner order, `ULx, ULy, LLx, LLy,
|
||||
// URx, URy, LRx, LRy`: the reference has no 4-value getter.
|
||||
let got: (f32, f32, f32, f32, f32, f32, f32, f32) = s.eval("return t:GetTexCoord()").unwrap();
|
||||
assert_eq!(got, (0.1, 0.2, 0.1, 0.8, 0.6, 0.2, 0.6, 0.8));
|
||||
|
||||
// The 8-arg affine form (arg order UL, LL, UR, LR) lands as per-corner UVs in screen order
|
||||
// [TL, TR, BR, BL] — here a 90° rotation of the full texture.
|
||||
// The 8-argument form (UL, LL, UR, LR) lands in screen order [TL, TR, BR, BL]; a 90° turn here.
|
||||
s.run("t:SetTexCoord(0,1, 1,1, 0,0, 1,0)").unwrap();
|
||||
s.resolve();
|
||||
let tex = s
|
||||
@@ -73,9 +68,6 @@ fn set_tex_coord_changes_extracted_uv() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `SetFontObject("Name")` copies a registered [`FontObject`]'s face/height/color onto the
|
||||
/// FontString; `GetFontObject` hands back the OBJECT (whose `GetName` round-trips); a later call
|
||||
/// re-points it (the resolved paint on the extracted quad follows). An unknown name errors.
|
||||
#[test]
|
||||
fn set_font_object_repoints_fontstring() {
|
||||
let mut s = script();
|
||||
@@ -144,7 +136,6 @@ fn set_font_object_repoints_fontstring() {
|
||||
)
|
||||
);
|
||||
|
||||
// Re-point.
|
||||
s.run("fs:SetFontObject('Small')").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
@@ -156,13 +147,10 @@ fn set_font_object_repoints_fontstring() {
|
||||
)
|
||||
);
|
||||
|
||||
// An unknown font object errors (never a silent no-op).
|
||||
assert!(s.run("fs:SetFontObject('Nope')").is_err());
|
||||
}
|
||||
|
||||
/// `justifyV`: MIDDLE is the default (the client's own FontString default — what seats the money
|
||||
/// numbers on their coins' centerline), `SetJustifyV` overrides it, and a font object carrying one
|
||||
/// applies it on `SetFontObject`.
|
||||
/// A FontString's `justifyV` defaults to MIDDLE, the client's default.
|
||||
#[test]
|
||||
fn justify_v_defaults_middle_and_overrides() {
|
||||
let mut s = script();
|
||||
@@ -192,15 +180,9 @@ fn justify_v_defaults_middle_and_overrides() {
|
||||
s.resolve();
|
||||
assert_eq!(justify_v(&s), JustifyV::Bottom);
|
||||
|
||||
// A font object carrying justify_v applies it — but ONLY on the axis this string has not
|
||||
// already claimed for itself. `SetJustifyV` above severed the V axis, and that severance is
|
||||
// permanent: the reference clears the axis's inheritMask bit (`+0x124`, the per-axis justify
|
||||
// mask) and never restores it, so a later `SetFontObject` cannot take the axis back.
|
||||
//
|
||||
// This assertion used to read `Top`. That was our copy-everything model, not the client's; the
|
||||
// shipped XML path is unaffected either way, because the loader applies `inherits=` BEFORE the
|
||||
// element's own `justifyV=` (`Loader::apply_fontstring_font`), so the explicit attribute still
|
||||
// wins.
|
||||
// A font object's justification applies only on an axis the string has not set itself: a
|
||||
// local setter clears that axis's inherit bit (`+0x124`) for good. XML is unaffected, as the
|
||||
// loader applies `inherits=` before the element's own `justifyV=`.
|
||||
s.register_font_object(
|
||||
"TopFont",
|
||||
FontObject {
|
||||
@@ -217,7 +199,7 @@ fn justify_v_defaults_middle_and_overrides() {
|
||||
s.resolve();
|
||||
assert_eq!(justify_v(&s), JustifyV::Bottom, "severance is permanent");
|
||||
|
||||
// A string that never claimed the axis DOES take the object's justification.
|
||||
// A string that never set the axis takes the object's justification.
|
||||
s.run(
|
||||
r#"
|
||||
fresh = f:CreateFontString(nil, "ARTWORK")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! The GameTooltip widget's engine mechanics (decision 0274): the line stack + named line
|
||||
//! regions, auto-size from the measure round-trip, the right-flush, SetOwner's anchor law +
|
||||
//! IsOwned, SetText's implicit show, both AddLine shapes, ClearLines/Hide firing
|
||||
//! `OnTooltipCleared`, SetMinimumWidth's floor, and FadeOut's ramp-then-hide.
|
||||
//! The GameTooltip widget's engine mechanics: the line stack and its auto-size, `SetOwner`'s
|
||||
//! anchor modes, the implicit and gated shows, and the fade.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
@@ -22,8 +20,6 @@ fn measure_all(s: &mut UiScript, sizes: &[(&str, f32, f32)]) {
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// The full stack: three lines (one double), measured, auto-sized, right-flushed, and the named
|
||||
/// line globals published.
|
||||
#[test]
|
||||
fn line_stack_autosize_and_right_flush() {
|
||||
let mut s = script();
|
||||
@@ -84,16 +80,8 @@ fn line_stack_autosize_and_right_flush() {
|
||||
assert_eq!(l2.top, 536.0);
|
||||
}
|
||||
|
||||
/// An EMPTY line mid-stack (the corpus' `AddLine("")`; the live shape was an empty-string
|
||||
/// subtitle from the wire): a ONE-UNIT row that also charges its 2px slot gap. Two fallbacks died
|
||||
/// to get here. First the owner-edge one: an empty FontString never measures, so its unpinned
|
||||
/// bottom edge fell back to the OWNER frame's bottom (0068's v1 region rule), the line stretched
|
||||
/// to the plate's bottom and the anchor chain marched every later line OUT of the plate (the live
|
||||
/// NPC-tooltip spill — name inside, "Level 20"/"PvP" below the frame under the health bar). Then
|
||||
/// the zero-span collapse that replaced it, which the reference does not have either: its size
|
||||
/// getters floor at one FrameXML unit, so `0.0` is not a span a FontString can have (decision
|
||||
/// 1664). The row and the plate read that floor off ONE constant, which is what keeps them from
|
||||
/// drifting apart the way B309's did.
|
||||
/// An empty line is a one-unit row plus its 2 px gap, as the reference's size getters floor a
|
||||
/// FontString at one FrameXML unit; the row and the plate read that floor from one constant.
|
||||
#[test]
|
||||
fn empty_line_is_a_one_unit_row_and_the_chain_stays_inside_the_plate() {
|
||||
let mut s = script();
|
||||
@@ -123,10 +111,7 @@ fn empty_line_is_a_one_unit_row_and_the_chain_stays_inside_the_plate() {
|
||||
],
|
||||
);
|
||||
// Rows 14 + 1 + 12 + 12 with 3 slot gaps ⇒ totalh 45, height 65; maxw 90 ⇒ width 110.
|
||||
// The blank row is ONE unit, not zero: a FontString's span floors at one FrameXML unit on
|
||||
// both the plate's metric and the line's own rect, from one constant (decision 1664).
|
||||
// (That this row exists at all is a separate open question — on the reference `AddLine("")`
|
||||
// returns without incrementing the line count, `0x530270`.)
|
||||
// The reference's `AddLine("")` returns without adding a line (`0x530270`); ours adds this row.
|
||||
s.run(
|
||||
r#"
|
||||
assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth()))
|
||||
@@ -145,8 +130,6 @@ fn empty_line_is_a_one_unit_row_and_the_chain_stays_inside_the_plate() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// SetOwner clears previous content (firing OnTooltipCleared), owns the frame for IsOwned, and
|
||||
/// Hide drops the owner + content and fires OnTooltipCleared again.
|
||||
#[test]
|
||||
fn owner_clear_and_hide_lifecycle() {
|
||||
let mut s = script();
|
||||
@@ -179,11 +162,9 @@ fn owner_clear_and_hide_lifecycle() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// SetText shows the tooltip implicitly (the PaperDoll empty-slot flow calls no Show); AddLine
|
||||
/// does not (the corpus' `AddLine … Show()`). The colour law: positional `r, g, b` apply only
|
||||
/// when the r-slot is a number — the corpus' archaic `(text, "", r, g, b)` shape has a string
|
||||
/// there, so the real 1.12 binding drops the whole colour tail and renders the DEFAULT GOLD
|
||||
/// (the reference's zone tooltip, director-matched; the trailing numbers never shift into place).
|
||||
/// `SetText` shows the tooltip and `AddLine` does not. `r, g, b` apply only when the r slot is a
|
||||
/// number, so `AddLine(text, "", r, g, b)` draws in the default gold, as the reference's zone
|
||||
/// tooltip does.
|
||||
#[test]
|
||||
fn settext_shows_and_both_addline_shapes() {
|
||||
let mut s = script();
|
||||
@@ -228,20 +209,17 @@ fn settext_shows_and_both_addline_shapes() {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no quad for {needle}"))
|
||||
};
|
||||
// The engine default gold 0xffffd200 (both no-colour shapes land on it).
|
||||
// The default gold `0xffffd200`, where both no-colour shapes land.
|
||||
let gold = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
assert_eq!(color_of("Zone Name"), gold);
|
||||
assert_eq!(color_of("plain gold"), gold);
|
||||
assert_eq!(color_of("wrapped tail"), [0.2, 0.4, 0.6, 1.0]);
|
||||
// The partial tail: r gates the block on, missing g/b read as 0.0 (byte-pinned).
|
||||
// A numeric r gates the block on, and a missing g or b reads as 0.
|
||||
assert_eq!(color_of("red only"), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A tooltip whose lines haven't been measured yet holds its declared size — the gaps alone must
|
||||
/// never resolve it to a gaps-only rect (the first cut summed LINE_GAP/DOUBLE_GAP for unmeasured
|
||||
/// rows and collapsed a fresh 4-line tooltip to 60×26; caught during the 0274 call-site
|
||||
/// migration).
|
||||
/// Until its lines are measured, a tooltip keeps its declared size rather than a gaps-only one.
|
||||
#[test]
|
||||
fn unmeasured_lines_hold_declared_size() {
|
||||
let mut s = script();
|
||||
@@ -260,7 +238,7 @@ fn unmeasured_lines_hold_declared_size() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Resolve WITHOUT answering the measure round-trip: the declared 120×32 must hold.
|
||||
// Resolve without answering the measure: the declared 120×32 holds.
|
||||
s.resolve();
|
||||
s.run(
|
||||
r#"
|
||||
@@ -284,7 +262,6 @@ fn unmeasured_lines_hold_declared_size() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// SetMinimumWidth floors the auto width (the money row's law), and clears with the content.
|
||||
#[test]
|
||||
fn minimum_width_floors_autosize() {
|
||||
let mut s = script();
|
||||
@@ -307,8 +284,7 @@ fn minimum_width_floors_autosize() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// FadeOut ramps the frame alpha down and hides at the end of the ramp (owner + lines dropped,
|
||||
/// OnTooltipCleared fired); fresh content mid-fade cancels the ramp at full alpha.
|
||||
/// Fresh content mid-fade cancels the ramp at full alpha.
|
||||
#[test]
|
||||
fn fadeout_ramps_then_hides() {
|
||||
let mut s = script();
|
||||
@@ -342,7 +318,6 @@ fn fadeout_ramps_then_hides() {
|
||||
mid_alpha > 0.2 && mid_alpha < 0.8,
|
||||
"mid-fade alpha ~0.5, got {mid_alpha}"
|
||||
);
|
||||
// Re-content cancels the fade at full alpha.
|
||||
s.run(
|
||||
r#"
|
||||
TT5:SetText("Fresh Hover")
|
||||
@@ -360,7 +335,7 @@ fn fadeout_ramps_then_hides() {
|
||||
})
|
||||
.expect("fresh line draws");
|
||||
assert_eq!(fresh_alpha, 1.0, "fresh content restored full alpha");
|
||||
// Run a full ramp to the end: hidden + cleared.
|
||||
// A full ramp ends hidden and cleared.
|
||||
s.run("TT5:FadeOut()").unwrap();
|
||||
s.tick(0.6);
|
||||
s.run(
|
||||
@@ -374,12 +349,8 @@ fn fadeout_ramps_then_hides() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// SetOwner's anchor law at the screen edge CLAMPS — the client's geometry-flags-bit4 clamp
|
||||
/// (`assemble 0x767a20`), carried by every GameTooltip frame **by
|
||||
/// construction** (decision 0352: no tooltip ever leaves the window). The reproduction is the
|
||||
/// minimap zone-text hover (MinimapCluster.xml: `ANCHOR_LEFT` on a button at the very top of
|
||||
/// the screen — plate bottom-right at the owner's top-left seats it wholly ABOVE the window):
|
||||
/// the reference plate hangs DOWN from the screen top instead, size preserved, X untouched.
|
||||
/// Every GameTooltip clamps to the screen (the geometry-flags bit 4 clamp, `assemble 0x767a20`), so
|
||||
/// the zone text's `ANCHOR_LEFT` (`Minimap.lua:39`) hangs down from the screen top, not above it.
|
||||
#[test]
|
||||
fn owner_anchored_tooltip_clamps_to_screen() {
|
||||
let mut s = script();
|
||||
@@ -407,8 +378,7 @@ fn owner_anchored_tooltip_clamps_to_screen() {
|
||||
],
|
||||
);
|
||||
// Auto-size: maxw 120 ⇒ width 140; totalh 14+2+12 ⇒ height 48. Unclamped, the plate's
|
||||
// BOTTOMRIGHT sits at the owner's TOPLEFT (610, 600) — top 648, wholly above the window.
|
||||
// The clamp shifts it back down: top at the screen top, size preserved, X untouched.
|
||||
// BOTTOMRIGHT at the owner's TOPLEFT (610, 600) puts its top at 648.
|
||||
s.run(
|
||||
r#"
|
||||
assert(TTC:GetTop() == 600, "top clamped to the screen top, got " .. TTC:GetTop())
|
||||
@@ -419,7 +389,7 @@ fn owner_anchored_tooltip_clamps_to_screen() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// The flag routes: unclamped, the plate returns to the raw anchor law above the window.
|
||||
// Unclamped, the plate goes back above the window.
|
||||
s.run(
|
||||
r#"
|
||||
assert(not TTC:IsClampedToScreen(), "flag readable")
|
||||
@@ -431,14 +401,11 @@ fn owner_anchored_tooltip_clamps_to_screen() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `0x52fa50`'s ladder as pure logic — the arm thresholds, the
|
||||
/// ceil/truncate asymmetry, and the `_P1` pick — over SYNTHETIC templates, so this asserts the
|
||||
/// mechanism and carries none of the reference's own eight strings. The real ones are read off the
|
||||
/// player's install and exercised in `benilla::ui_script::buff_tests`.
|
||||
/// `0x52fa50`'s duration ladder over stand-in templates: the arm edges, the ceiling on every arm
|
||||
/// but seconds, and the `_P1` plural pick.
|
||||
#[test]
|
||||
fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
// Deliberately not the shipped wording: what is under test is which key is reached and what
|
||||
// number fills it, never what the string says.
|
||||
// Not the shipped wording: the test is which key is reached and what number fills it.
|
||||
let table = |key: &str| -> Option<String> {
|
||||
Some(
|
||||
match key {
|
||||
@@ -457,8 +424,7 @@ fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
};
|
||||
let d = |ms: u32| crate::script::tooltip::duration_text(ms, "T", true, &table);
|
||||
|
||||
// The three arm boundaries, from the compare instructions — each is exact, and the arm below
|
||||
// it counts in its own unit right up to the edge.
|
||||
// The three arm edges are exact, and the arm below counts in its own unit up to the edge.
|
||||
assert_eq!(d(86_400_000).as_deref(), Some("<1 day>"), "the day edge");
|
||||
assert_eq!(
|
||||
d(86_399_999).as_deref(),
|
||||
@@ -478,8 +444,7 @@ fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
"the worked example: 61 s ceils to 2, it does not truncate to 1"
|
||||
);
|
||||
|
||||
// The asymmetry that makes this one function rather than four format calls: roundUp reaches
|
||||
// the top three arms only.
|
||||
// roundUp reaches the top three arms only.
|
||||
assert_eq!(
|
||||
d(59_999).as_deref(),
|
||||
Some("<59 secs>"),
|
||||
@@ -498,18 +463,18 @@ fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
"exactly one is singular"
|
||||
);
|
||||
|
||||
// roundUp = 0 truncates the top arms too (the parameter is real, not a constant we folded).
|
||||
// roundUp = 0 truncates the top arms too.
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(61_000, "T", false, &table).as_deref(),
|
||||
Some("<1 min>"),
|
||||
);
|
||||
|
||||
// A key the string table does not carry yields NO LINE rather than an invented one.
|
||||
// A key the string table lacks yields no line.
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(1_000, "NOPE", true, &table),
|
||||
None
|
||||
);
|
||||
// …and a family shipping only the singular falls back to it rather than vanishing.
|
||||
// A family with only the singular falls back to it.
|
||||
let singular_only = |key: &str| (key == "S_SEC").then(|| "<%d s>".to_string());
|
||||
assert_eq!(
|
||||
crate::script::tooltip::duration_text(5_000, "S", true, &singular_only).as_deref(),
|
||||
@@ -517,20 +482,9 @@ fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **B309 — a POOLED line cell that goes empty must not keep the box it measured last hover.**
|
||||
///
|
||||
/// `measured` is a cache the solver honours without a key check on purpose: it is the last-known
|
||||
/// box, held so a line whose text just changed does not collapse for the frame its re-measure is
|
||||
/// in flight. Empty text is the one case where that measure never comes — both measure asks
|
||||
/// filter empty strings out — so before the fix an emptied cell held its dead box FOREVER, and
|
||||
/// only the DRAWN geometry was wrong: the plate's own sum calls empty text zero
|
||||
/// (`tooltip::cell`) and the Lua getters key-check (`region::measured_wh`), so both said 0 while
|
||||
/// the solver stood a full row there.
|
||||
///
|
||||
/// Live, that is the item tooltip's SET block: `ClearLines` keeps the cache (the hover re-enter
|
||||
/// loop depends on it), so the two blank gold spacers land on cells that carried real text on an
|
||||
/// earlier hover. Each drew an uncounted row and the set bonuses hung below the backdrop —
|
||||
/// on Field Marshal's Raiment, two blanks, two rows.
|
||||
/// The solver keeps a line's last measured box, unkeyed, while a re-measure is in flight, and an
|
||||
/// empty line is never measured, so a pooled cell that goes empty must drop its old box: the item
|
||||
/// tooltip's set block puts blank spacers on cells that held text.
|
||||
#[test]
|
||||
fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_chain() {
|
||||
let mut s = script();
|
||||
@@ -556,8 +510,7 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c
|
||||
("PvP", 24.0, 12.0),
|
||||
];
|
||||
measure_all(&mut s, sizes);
|
||||
// Hover two, SAME pooled cells: line 2 is now the blank spacer. Nothing new to measure —
|
||||
// lines 1 and 3 re-validate against their own keys, and an empty string is never asked for.
|
||||
// Hover two, same pooled cells: line 2 is now the blank spacer, which is never measured.
|
||||
s.run(
|
||||
r#"
|
||||
TTR:ClearLines()
|
||||
@@ -590,21 +543,11 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`SetOwner`'s omitted `anchorType` is mode 0 = ANCHOR_LEFT**, and the clear-vs-keep split runs
|
||||
/// between NONE and PRESERVE, not between "points" and "doesn't".
|
||||
///
|
||||
/// 2142's open thread 1 predicted the opposite on both counts — a mode-7 default, and a NONE arm
|
||||
/// that must stop dropping anchors — and the bytes refute both (decision 2176). What is actually
|
||||
/// there:
|
||||
///
|
||||
/// * `0x53120d` zeroes the binding's local mode before any compare, and `0x531214`'s
|
||||
/// `lua_isstring` gate jumps the whole `SStrCmpI` chain when arg 3 is absent, nil, a boolean or
|
||||
/// a table — so the answer is **0 = ANCHOR_LEFT**, silently, with no `luaL_error` anywhere in
|
||||
/// `[0x531221, 0x53133a)`. The core's `mov [+0x318],7` at `0x530012` is an unconditional
|
||||
/// pre-store `0x530031` overwrites; it survives only when the owner is NULL.
|
||||
/// * `0x52fe90` returns for mode **8** at `0x52fead`, *before* `0x52fec2 call 0x767ed0`, and the
|
||||
/// mode-7 skip beside that clear is gated on an arg1 `SetOwner`'s core always passes as 1. So
|
||||
/// every mode 0..7 clears, and only PRESERVE keeps the placement.
|
||||
/// An omitted `anchorType` is mode 0, ANCHOR_LEFT: `0x53120d` zeroes the local, the `lua_isstring`
|
||||
/// gate at `0x531214` skips the compare chain, and nothing in `[0x531221, 0x53133a)` raises. The
|
||||
/// core's pre-store of mode 7 (`0x530012`) survives only a NULL owner (`0x530031`). `0x52fe90`
|
||||
/// returns for mode 8 (`0x52fead`) before its clear (`0x52fec2 call 0x767ed0`), and `SetOwner`
|
||||
/// always passes the arg that gates mode 7's skip as 1, so only ANCHOR_PRESERVE keeps the points.
|
||||
#[test]
|
||||
fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
let mut s = script();
|
||||
@@ -623,7 +566,7 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Plate:GetLeft()").unwrap(), 100.0);
|
||||
|
||||
// ANCHOR_PRESERVE (mode 8) is the ONE mode that leaves the plate alone.
|
||||
// ANCHOR_PRESERVE (mode 8) is the one mode that leaves the plate alone.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_PRESERVE")"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
@@ -634,7 +577,7 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return Plate:GetNumPoints()").unwrap(), 1);
|
||||
|
||||
// ANCHOR_NONE (mode 7) clears — which is what makes `GameTooltip_SetDefaultAnchor` safe.
|
||||
// ANCHOR_NONE (mode 7) clears, which `GameTooltip.lua:73`'s default anchor relies on.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_NONE")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return Plate:GetNumPoints()").unwrap(),
|
||||
@@ -642,8 +585,7 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
"mode 7 reaches the clear: arg1 is always 1 from SetOwner's core"
|
||||
);
|
||||
|
||||
// `SetOwner(f)` — no anchor string at all. Mode 0, and mode 0 PLACES: ANCHOR_LEFT pins the
|
||||
// plate's BOTTOMRIGHT to the owner's TOPLEFT, so its right edge sits at the owner's left.
|
||||
// No anchor string: mode 0 places the plate's BOTTOMRIGHT at the owner's TOPLEFT.
|
||||
s.run(r#"Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)"#)
|
||||
.unwrap();
|
||||
s.run("Plate:SetOwner(Owner)").unwrap();
|
||||
@@ -659,8 +601,7 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
"the owner's left edge (300) less the plate's own 50 wide"
|
||||
);
|
||||
|
||||
// An UNRECOGNISED string is the same silent mode 0 — the compare chain falls through with the
|
||||
// local untouched, and raises nothing.
|
||||
// An unrecognised string falls through the compare chain: the same silent mode 0.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_SIDEWAYS")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -669,11 +610,7 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetAlpha(255)` on SetOwner is UNCONDITIONAL** — `0x52fff4`, the first thing the SetOwner
|
||||
/// core `0x52ffe0` does, before its five stores (`0x52ffe0`). Ours only ever restored the alpha one
|
||||
/// of OUR OWN fades had taken away, so a plate left dim by any other path — an addon's `SetAlpha`,
|
||||
/// an inherited parent alpha — stayed dim through every subsequent hover, where the reference
|
||||
/// stamps it back to full on each SetOwner.
|
||||
/// The `SetOwner` core `0x52ffe0` first sets alpha 255 (`0x52fff4`), whatever dimmed the plate.
|
||||
#[test]
|
||||
fn set_owner_stamps_full_alpha_even_with_no_fade_running() {
|
||||
let s = script();
|
||||
@@ -694,19 +631,9 @@ fn set_owner_stamps_full_alpha_even_with_no_fade_running() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **`Show` is an EXISTENCE GATE, not a plain show** — `0x530a80`, the CGameTooltip override of
|
||||
/// `vtbl+0x88` (the slot a Lua `:Show()` lands in). It shows only when BOTH the owner `+0x314`
|
||||
/// and the line count `+0x31c` are non-zero, and otherwise calls its own `vtbl+0x84`
|
||||
/// effective-hide `0x530a60` — the SetOwner core with a NULL owner, so the plate is hidden AND
|
||||
/// un-owned (`0x530a80`).
|
||||
///
|
||||
/// The symptom that found it: `Questie`'s tracker draws an EMPTY plate on hover — correctly
|
||||
/// sized and bordered, with no text. `QuestieTracker.lua`'s quest-button OnEnter has a dead zone
|
||||
/// between its two arms (an in-progress quest that has objectives AND whose objective text is in
|
||||
/// the Questie database matches neither), so it adds no line and then calls `Tooltip:Show()`
|
||||
/// unconditionally. The reference swallows that; we drew it — and, because `layout_tooltips`
|
||||
/// skips a zero-line plate instead of collapsing it, we drew it at the LAST hover's size, which
|
||||
/// is why it looked like a real tooltip with the text missing rather than a stub.
|
||||
/// `Show` (`0x530a80`, the `vtbl+0x88` override) shows only with an owner (`+0x314`) and a line
|
||||
/// (`+0x31c`); otherwise it calls `0x530a60` (`vtbl+0x84`), the `SetOwner` core with a NULL owner,
|
||||
/// which hides and un-owns. `QuestieTracker.lua`'s OnEnter can call `Show` with no line.
|
||||
#[test]
|
||||
fn show_with_no_lines_self_hides_and_un_owns() {
|
||||
let mut s = script();
|
||||
@@ -726,8 +653,7 @@ fn show_with_no_lines_self_hides_and_un_owns() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Questie's exact shape: the re-hover's SetOwner clears the content, the handler's dead zone
|
||||
// adds nothing, and Show() runs anyway.
|
||||
// Questie's shape: a re-hover's `SetOwner` clears the lines, nothing is added, `Show` runs.
|
||||
s.run(
|
||||
r#"
|
||||
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
|
||||
@@ -739,7 +665,7 @@ fn show_with_no_lines_self_hides_and_un_owns() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The other half of the gate, on its own: a line but no owner is equally not shown.
|
||||
// A line but no owner is the other half of the gate.
|
||||
s.run(
|
||||
r#"
|
||||
Tip:AddLine("Orphan", 1, 1, 1)
|
||||
@@ -750,20 +676,9 @@ fn show_with_no_lines_self_hides_and_un_owns() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **A NON-STRING anchor argument must not raise** — and for a year it did, because the binding
|
||||
/// took arg 3 as `Option<String>` and let mlua's converter be the gate.
|
||||
///
|
||||
/// The reference's gate is `lua_isstring 0x6f3510`, whose entire body is `lua_type` then
|
||||
/// `cmp eax,4 / cmp eax,3`: LUA_TSTRING or LUA_TNUMBER pass and are read by coercion, and every
|
||||
/// other tag takes `0x53121b je 0x53133a` over the whole compare chain to the join, leaving the
|
||||
/// zeroed local — mode 0, `ANCHOR_LEFT`, silently, with no `luaL_error` anywhere in
|
||||
/// `[0x531221, 0x53133a)`. So a boolean, a table, a function and absent are *indistinguishable*
|
||||
/// at this position.
|
||||
///
|
||||
/// The symptom that found it: `Questie` hovers a world-map note with
|
||||
/// `Tooltip:SetOwner(this, this)` (`QuestieNotes.lua` `Questie_Tooltip_OnEnter`) — a frame where
|
||||
/// the anchor goes — and got `bad argument #3: error converting Lua table to String` where the
|
||||
/// reference draws the tooltip.
|
||||
/// `lua_isstring 0x6f3510` passes only a string or a number; any other tag jumps the compare chain
|
||||
/// (`0x53121b je 0x53133a`) to mode 0 and raises nothing. `QuestieNotes.lua` passes a frame here
|
||||
/// (`SetOwner(this, this)`).
|
||||
#[test]
|
||||
fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
|
||||
let s = script();
|
||||
@@ -775,7 +690,7 @@ fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A TABLE — Questie's own call shape, a frame passed where the anchor string goes.
|
||||
// A table, as Questie passes.
|
||||
s.run("Plate:SetOwner(Owner, Owner)")
|
||||
.expect("a table anchor takes the isstring gate's jump, it does not raise");
|
||||
assert_eq!(
|
||||
@@ -783,7 +698,7 @@ fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
|
||||
"ANCHOR_LEFT"
|
||||
);
|
||||
|
||||
// A BOOLEAN and an explicit nil are the same jump.
|
||||
// A boolean and an explicit nil take the same jump.
|
||||
for arg in ["true", "false", "nil"] {
|
||||
s.run(&format!(
|
||||
r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, {arg})"#
|
||||
@@ -796,8 +711,7 @@ fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
|
||||
);
|
||||
}
|
||||
|
||||
// A NUMBER is the one non-string tag the gate DOES admit: it is stringified and run through
|
||||
// the chain, which no number can match — the same mode 0, by the other route.
|
||||
// A number passes the gate as a string that matches no mode: mode 0 again.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, 5)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -806,11 +720,8 @@ fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`ANCHOR_CURSOR` is a real ninth mode**, not a string to warn about (2142's open thread 2, and
|
||||
/// nine corpus files ask for it): mode 6 clears at `SetOwner` time like mode 7, and then the
|
||||
/// per-frame update `0x530b20` pins the plate's **BOTTOM** to the screen's **BOTTOMLEFT** at the
|
||||
/// cursor's absolute position, divided by the plate's own effective scale. See
|
||||
/// `script::tooltip::cursor_anchor`.
|
||||
/// `ANCHOR_CURSOR`, mode 6, clears at `SetOwner` like mode 7; the per-frame update `0x530b20` then
|
||||
/// pins the plate's BOTTOM to the screen's BOTTOMLEFT at the cursor, over its effective scale.
|
||||
#[test]
|
||||
fn anchor_cursor_follows_the_cursor_every_frame() {
|
||||
let mut s = script();
|
||||
@@ -851,7 +762,7 @@ fn anchor_cursor_follows_the_cursor_every_frame() {
|
||||
"centred horizontally on the cursor: {left}..{right}"
|
||||
);
|
||||
|
||||
// It follows. The whole point of mode 6 is that no second SetOwner is needed.
|
||||
// It follows the cursor with no second `SetOwner`.
|
||||
s.mouse_move(120.0, 480.0);
|
||||
s.resolve();
|
||||
let (left, right, bottom): (f32, f32, f32) = s
|
||||
@@ -864,14 +775,8 @@ fn anchor_cursor_follows_the_cursor_every_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `GameTooltip:GetAnchorType()` — ONE string, the reference's own spelling, round-tripping
|
||||
/// whatever `SetOwner` was given (`0x5313e0`, table `0x854198`, argc 1, arity 1, kinds `(string)`,
|
||||
/// reading `[+0x318]` back through the name table `0x531530`).
|
||||
///
|
||||
/// The round trip is the whole point rather than a nicety: `_Nameplates/_Nameplates.lua:479` is
|
||||
/// `if GameTooltip:GetAnchorType() ~= Anchor then GameTooltip:SetOwner(Column, Anchor) end`, so an
|
||||
/// answer that never equals what the setter was handed would re-own the plate every OnUpdate; and
|
||||
/// `pfUI/modules/tooltip.lua:97` compares against the literal `"ANCHOR_NONE"`.
|
||||
/// `GetAnchorType` (`0x5313e0`, table `0x854198`) answers one string, `[+0x318]` through the name
|
||||
/// table `0x531530`; `_Nameplates.lua:479` compares it with what it passed to `SetOwner`.
|
||||
#[test]
|
||||
fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
let s = script();
|
||||
@@ -883,8 +788,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Arity 1, kind string, and NO nil leg — the shapes row is `(string)` with no alternative, so
|
||||
// a plate nobody has owned still answers a string.
|
||||
// Never nil: a plate nobody has owned answers a string too.
|
||||
assert_eq!(s.arity("AnchorTip:GetAnchorType()").unwrap(), 1, "arity 1");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(AnchorTip:GetAnchorType())")
|
||||
@@ -899,8 +803,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
"a plate nothing has owned is anchored to nothing"
|
||||
);
|
||||
|
||||
// All NINE modes SetOwner accepts, each answered back verbatim — ANCHOR_PRESERVE included,
|
||||
// which the reference stores like any other rather than resolving back to what it preserved.
|
||||
// All nine modes round-trip, ANCHOR_PRESERVE included: the reference stores it like any other.
|
||||
for mode in [
|
||||
"ANCHOR_RIGHT",
|
||||
"ANCHOR_LEFT",
|
||||
@@ -922,7 +825,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
);
|
||||
}
|
||||
|
||||
// Lower case in, the reference's canonical spelling out — SetOwner already folds the token.
|
||||
// Lower case in, the canonical spelling out.
|
||||
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "anchor_left")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -931,9 +834,8 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
"ANCHOR_LEFT"
|
||||
);
|
||||
|
||||
// An anchor SetOwner does not recognise is the binding's zero-initialised local, mode 0 —
|
||||
// silently, with no raise (`0x53120d`, and no `luaL_error` in the compare chain). Ours adds a
|
||||
// warning the reference does not have, which is a diagnostic and not API surface.
|
||||
// An unrecognised anchor is mode 0, with no raise (`0x53120d`). Deviation: we also log a
|
||||
// warning so the unknown token shows up in diagnostics; no addon can see it.
|
||||
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_SIDEWAYS")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
//! The Set* entry points: the vendor compare's CURRENTLY_EQUIPPED shape,
|
||||
//! SetInventoryItem outside compare, and SetHyperlink's item-link filter.
|
||||
//! The `Set*` entry points: the vendor compare, `SetInventoryItem` outside it, `SetHyperlink`'s
|
||||
//! item filter, and the tradeskill and merchant-compare row indexing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// The compare SHAPE, driven the only way 1.12.1 drives it: the vendor row's
|
||||
/// `SetMerchantCompareItem` (`MerchantFrame.xml:63-80`). The armed render is the equipped item's
|
||||
/// ORDINARY tooltip plus ONE line, first — the gray CURRENTLY_EQUIPPED header (p5). Both compare
|
||||
/// call sites pass p4 (compact) ZERO, so the two things that are easy to get wrong here are
|
||||
/// pinned as negatives: the name keeps its QUALITY color (this worn ring is epic, so the assert
|
||||
/// bites), and nothing is cut at `0x52e14c` — the description still prints (2216).
|
||||
///
|
||||
/// **And the negative control that keeps this engine out of it** (2210): a bag hover seats
|
||||
/// nothing, with shift or without, because the reference has no hover compare at all —
|
||||
/// `SHOW_COMPARE_TOOLTIP` has zero fire sites in 5875, so nothing may reach a listener for it
|
||||
/// either. The plates belong to whatever FrameXML raises them, and this engine raises none.
|
||||
/// The compare as 1.12.1 drives it, from the vendor row's `SetMerchantCompareItem`
|
||||
/// (`MerchantFrame.xml:63-80`): the worn item's ordinary tooltip under a gray
|
||||
/// `CURRENTLY_EQUIPPED` header (p5). Both callers pass p4 zero, so the name keeps its quality
|
||||
/// colour and nothing is cut at `0x52e14c`. A bag hover seats no plate, shift or not:
|
||||
/// `SHOW_COMPARE_TOOLTIP` has no fire site in the reference.
|
||||
#[test]
|
||||
fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
let mut s = script();
|
||||
@@ -26,8 +20,7 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
durability: None,
|
||||
item_id: 7000,
|
||||
name: Some("Old Loop".into()),
|
||||
// EPIC — a white name and a quality name are the same pixel at quality 1, which is how
|
||||
// the conflated flag survived a green test for as long as it did.
|
||||
// Epic: at quality 1 a white name and a quality name are the same pixel.
|
||||
quality: 4,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -98,8 +91,7 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
// The plates are ordinary GameTooltip frames the FrameXML owns — the engine knows nothing
|
||||
// about them (`ShoppingTooltip` does not occur in the image at all).
|
||||
// The plates are ordinary GameTooltips the FrameXML owns; the reference never names them.
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("LEFT", 0, 0); a:SetWidth(10); a:SetHeight(10)
|
||||
@@ -175,7 +167,6 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The compare colors: gray header, and the name in its own QUALITY color — the byte law.
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let color_of = |txt: &str| {
|
||||
@@ -206,16 +197,11 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`nameOnly`** — `SetInventoryItem`'s optional third argument, p4 of the builder, and the ONLY
|
||||
/// door onto the compact render in 1.12.1 (27 of `0x52b650`'s 31 call sites pass a provable zero,
|
||||
/// one forwards, and the three that carry a flag are all this binding's). No stock FrameXML caller
|
||||
/// passes it — all 8 stock call sites are two-argument — so this is addon surface, and it is live
|
||||
/// code, not a dead arm.
|
||||
///
|
||||
/// The mode is **trimmed, not bare**, and that is the half a plausible implementation gets wrong:
|
||||
/// two non-contiguous cuts plus an early return. Gone: the bind/lock region, the whole stat body,
|
||||
/// and everything past the cooldown line. Kept: the name (white), the slot/type cell, durability,
|
||||
/// every requirement line and the spell triggers.
|
||||
/// `SetInventoryItem`'s third argument, `nameOnly`, is the builder's p4 and the one way to the
|
||||
/// compact render: of `0x52b650`'s 31 call sites, 27 pass zero, one forwards and three are this
|
||||
/// binding's, and no stock caller passes it. The render is trimmed, not bare: the bind and lock
|
||||
/// lines, the stat body and everything past the cooldown line go; the white name, the slot and
|
||||
/// type cell, durability, the requirements and the spell triggers stay.
|
||||
#[test]
|
||||
fn set_inventory_item_name_only_is_the_trimmed_build() {
|
||||
let mut s = script();
|
||||
@@ -237,9 +223,7 @@ fn set_inventory_item_name_only_is_the_trimmed_build() {
|
||||
class: 2,
|
||||
subclass: 7,
|
||||
inventory_type: 13,
|
||||
// One line from each of the three regions p4 treats differently: CONJURED / the bind
|
||||
// line / UNIQUE / LOCKED are CUT, ARMOR is CUT, and the type cell, durability, the
|
||||
// level requirement and the trigger all SURVIVE.
|
||||
// A line from each region p4 treats differently.
|
||||
flags: 0x2,
|
||||
bonding: 1,
|
||||
max_count: 1,
|
||||
@@ -312,8 +296,7 @@ fn set_inventory_item_name_only_is_the_trimmed_build() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `SetInventoryItem` outside a compare: the FULL line law (quality name, description — no cut),
|
||||
/// return 1 on an occupied slot, nil on empty/foreign units.
|
||||
/// Outside a compare: the full render, 1 for an occupied slot, nil for an empty or foreign one.
|
||||
#[test]
|
||||
fn set_inventory_item_renders_full_outside_compare() {
|
||||
let mut s = script();
|
||||
@@ -377,8 +360,6 @@ fn set_inventory_item_renders_full_outside_compare() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `SetHyperlink`: the full escaped chat link and the bare `item:` form both render through the
|
||||
/// shared law; non-item links no-op without touching the current content.
|
||||
#[test]
|
||||
fn set_hyperlink_renders_items_and_ignores_other_links() {
|
||||
let mut s = script();
|
||||
@@ -410,8 +391,7 @@ fn set_hyperlink_renders_items_and_ignores_other_links() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// One tradeskill recipe fixture — the product id is `spell_id + 10_000`, so the tooltip's own
|
||||
/// name names which recipe the channel actually landed on.
|
||||
/// A recipe whose product id is `spell_id + 10_000`, so the tooltip's name shows which row it hit.
|
||||
fn ts_recipe(spell_id: u32, name: &str, group: (u32, u32, &str)) -> TradeSkillRecipe {
|
||||
TradeSkillRecipe {
|
||||
group: Some((group.0, group.1, group.2.to_string())),
|
||||
@@ -437,13 +417,8 @@ fn ts_recipe(spell_id: u32, name: &str, group: (u32, u32, &str)) -> TradeSkillRe
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetTradeSkillItem`'s index is a **VISIBLE** row index, not a position in `recipes` — headers
|
||||
/// interleave with rows, so the two differ the moment any group precedes the recipe. This is the
|
||||
/// exact mis-index the tradeskill module doc once carried as a known gap: with one header above it,
|
||||
/// visible row 4 is the SECOND group's recipe, and a raw `recipes[4-1]` lookup would land on the
|
||||
/// first group's second recipe instead. Both the product channel and the reagent channel are
|
||||
/// pinned, since the detail-icon and reagent-slot hovers are the two callers that pass a genuine
|
||||
/// visible index straight through. A HEADER row is a no-op, not a shifted hit.
|
||||
/// `SetTradeSkillItem` takes a visible row index, headers included, not a position in `recipes`;
|
||||
/// the product and reagent hovers both pass one, and a header row is a no-op.
|
||||
#[test]
|
||||
fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() {
|
||||
let mut s = script();
|
||||
@@ -468,11 +443,7 @@ fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() {
|
||||
}));
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 6);
|
||||
|
||||
// Visible row 5 is "Alpha Cloak" (spell 4) — its product is 10_004. A raw recipes[5-1] lookup
|
||||
// would land on recipe index 4 (out of range, a silent no-op); recipes[4-1] on "Beta Cloak".
|
||||
// `TTTextLeft1` need not exist when a call renders nothing (what an out-of-range RAW index
|
||||
// does), so read it defensively — that case must surface as an assert_eq naming the row, not
|
||||
// as a Lua nil-index panic three lines away from the claim.
|
||||
// `TTTextLeft1` may not exist when nothing renders, so it is read defensively.
|
||||
let name_at = |s: &mut UiScript, row: i64, reagent: &str| -> String {
|
||||
s.run(&format!("TT:SetTradeSkillItem({row}{reagent})"))
|
||||
.unwrap();
|
||||
@@ -483,26 +454,20 @@ fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() {
|
||||
assert_eq!(name_at(&mut s, 6, ""), "Beta Cloak");
|
||||
assert_eq!(name_at(&mut s, 2, ""), "Alpha Bolt");
|
||||
|
||||
// The reagent channel takes the same mapping.
|
||||
assert_eq!(name_at(&mut s, 5, ", 1"), "Alpha Cloak Reagent");
|
||||
|
||||
// A header row resolves to nothing — the hover is a no-op, so the previous render stands
|
||||
// rather than a neighbouring recipe's tooltip appearing under the cursor.
|
||||
// A header row is a no-op: the previous render stands.
|
||||
assert_eq!(name_at(&mut s, 4, ""), "Alpha Cloak Reagent");
|
||||
}
|
||||
|
||||
/// `SetMerchantCompareItem(index [, offset])` — the shopping tooltip the stock vendor row raises.
|
||||
///
|
||||
/// The contract that decides ghost tooltips versus none is the RETURN, so that is what this pins:
|
||||
/// the **number** 1 on success, `nil` on every failure, one value always. Stock's
|
||||
/// `if ( ShoppingTooltip1:SetMerchantCompareItem(id, 1) ) then … end` reads it directly, so a
|
||||
/// boolean `false` where nil belongs shows an empty tooltip and a truthy nil-case shows two.
|
||||
/// `SetMerchantCompareItem(index [, offset])` returns the number 1 or nil, one value, and the stock
|
||||
/// vendor row shows each plate on it (`MerchantFrame.xml:67`).
|
||||
#[test]
|
||||
fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
|
||||
// Both finger slots worn, both class 4 (ARMOR) like the ring on the shelf.
|
||||
// Both finger slots worn, class 4 (armor) like the ring on the shelf.
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
for (slot, id, name) in [(11usize, 7000u32, "Old Loop"), (12, 7001, "Older Loop")] {
|
||||
inv[slot] = Some(InvSlotView {
|
||||
@@ -522,7 +487,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
},
|
||||
);
|
||||
}
|
||||
// …and a shield, class 4, in the off hand — the case that makes offset 2 nil for a WEAPON.
|
||||
// A shield, class 4, in the off hand makes offset 2 nil for a weapon.
|
||||
inv[17] = Some(InvSlotView {
|
||||
item_id: 7100,
|
||||
name: Some("Battered Buckler".into()),
|
||||
@@ -571,7 +536,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
},
|
||||
MerchantItem {
|
||||
name: Some("Unseen Thing".into()),
|
||||
item_id: 8999, // no template — the uncached leg
|
||||
item_id: 8999, // no template: the uncached leg
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
@@ -583,7 +548,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
name: "Shiny Loop".into(),
|
||||
quality: 3,
|
||||
class: 4,
|
||||
inventory_type: 11, // finger — two candidate slots
|
||||
inventory_type: 11, // finger: two candidate slots
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
@@ -593,13 +558,11 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
name: "Sharp Sword".into(),
|
||||
quality: 3,
|
||||
class: 2,
|
||||
inventory_type: 13, // one-hand — main hand then off hand
|
||||
inventory_type: 13, // one-hand: main hand, then off hand
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// The two shopping tooltips are ordinary GameTooltip frames — the engine knows nothing about
|
||||
// them (the substring `ShoppingTooltip` does not occur in the image at all).
|
||||
s.run(
|
||||
r#"
|
||||
CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide()
|
||||
@@ -611,7 +574,6 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
let call =
|
||||
|s: &mut UiScript, expr: &str| s.eval::<String>(&format!("return type({expr})")).unwrap();
|
||||
|
||||
// A ring against two worn rings: both offsets answer, and the answer is the NUMBER 1.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return tostring(ShoppingTooltip1:SetMerchantCompareItem(1, 1))"#)
|
||||
.unwrap(),
|
||||
@@ -622,7 +584,6 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
call(&mut s, "ShoppingTooltip2:SetMerchantCompareItem(1, 2)"),
|
||||
"number"
|
||||
);
|
||||
// …and it filled with the WORN item, headed by the gray compare line.
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
r#"local n = ShoppingTooltip1:NumLines()
|
||||
@@ -637,9 +598,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
"the fill is the equipped item's own tooltip with the CURRENTLY_EQUIPPED header"
|
||||
);
|
||||
|
||||
// A one-hand weapon: main hand matches (class 2), the off-hand SHIELD does not (class 4), so
|
||||
// offset 2 is nil. This is the reference's class test doing the work, and it is the case a
|
||||
// slot-only reading would get wrong.
|
||||
// A one-hand weapon: the main hand (class 2) matches, the off-hand shield (class 4) does not.
|
||||
assert_eq!(
|
||||
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(2, 1)"),
|
||||
"number"
|
||||
@@ -650,12 +609,12 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
"a shield is not a candidate for a weapon — class, not slot"
|
||||
);
|
||||
|
||||
// Offset defaults to 1 when absent, and does NOT raise.
|
||||
// An absent offset is 1, not a raise.
|
||||
assert_eq!(
|
||||
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(1)"),
|
||||
"number"
|
||||
);
|
||||
// An explicit 0 or a negative is nil — the counter starts below zero and only decrements.
|
||||
// An offset of 0 or less is nil: the counter starts below zero and only decrements.
|
||||
for bad in ["0", "-1"] {
|
||||
assert_eq!(
|
||||
call(
|
||||
@@ -666,7 +625,6 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
"offset {bad}"
|
||||
);
|
||||
}
|
||||
// Out of range, and the uncached template, are both nil — never an error.
|
||||
for expr in [
|
||||
"ShoppingTooltip1:SetMerchantCompareItem(0, 1)",
|
||||
"ShoppingTooltip1:SetMerchantCompareItem(99, 1)",
|
||||
@@ -674,20 +632,18 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
] {
|
||||
assert_eq!(call(&mut s, expr), "nil", "{expr}");
|
||||
}
|
||||
// 2.7 re-bases in f64 and THEN truncates: 2.7 - 1 = 1.7 -> row 1, the sword. Doing it the
|
||||
// other way round would land on row 2.
|
||||
// 2.7 re-bases in f64, then truncates: 1.7 is row 1, the sword.
|
||||
assert_eq!(
|
||||
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(2.7, 1)"),
|
||||
"number"
|
||||
);
|
||||
|
||||
// A non-number index RAISES, with the client's own usage text.
|
||||
let e = s
|
||||
.run(r#"ShoppingTooltip1:SetMerchantCompareItem({}, 1)"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("Usage: SetMerchantCompareItem"), "{e}");
|
||||
// …but a numeric STRING is a number to `lua_isnumber`.
|
||||
// A numeric string passes `lua_isnumber`.
|
||||
assert_eq!(
|
||||
call(&mut s, r#"ShoppingTooltip1:SetMerchantCompareItem("1", 1)"#),
|
||||
"number"
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! The emission-order law and the red requirement checks: the verified line families, the
|
||||
//! player-state-tracked reds (level/class/rep), and the slot|type cells' independent
|
||||
//! proficiency reds (hard miss on TYPE; alternate/dual-wield on SLOT).
|
||||
//! Line order and the red requirement checks: level, class and reputation against player state,
|
||||
//! and the slot and type cells' separate proficiency reds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{axe, lines_of, right_color, script};
|
||||
use crate::script::*;
|
||||
|
||||
/// The full line law over a rich weapon + the red checks: a level-25 warrior fails the level
|
||||
/// requirement (red) but passes the class list (white).
|
||||
#[test]
|
||||
fn item_line_law_and_red_requirements() {
|
||||
let mut s = script();
|
||||
@@ -16,7 +13,7 @@ fn item_line_law_and_red_requirements() {
|
||||
s.set_item_template(871, axe());
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 25,
|
||||
class_id: 1, // Warrior — allowed
|
||||
class_id: 1, // Warrior: allowed
|
||||
race_id: 1,
|
||||
skills: HashMap::new(),
|
||||
..Default::default()
|
||||
@@ -39,14 +36,12 @@ fn item_line_law_and_red_requirements() {
|
||||
"Ravager",
|
||||
"[ITEM_BIND_ON_EQUIP]",
|
||||
"[INVTYPE_2HWEAPON]",
|
||||
// School 0 is physical: the reference names no school word for it either.
|
||||
// The first emitted slot takes the plain key, a later one the `PLUS_` twin, and a
|
||||
// school'd slot the `_WITH_SCHOOL` arm — the word sits INSIDE the template.
|
||||
// School 0 has no school word. The first damage line takes the plain key, later ones
|
||||
// `PLUS_`, and a slot with a school the `_WITH_SCHOOL` arm, the word inside it.
|
||||
"[DMG 68 - 103]",
|
||||
"[+DMGS 2 - 4 [SCHOOL5]]",
|
||||
"[DPS 25.3]",
|
||||
// Display order, NOT wire order: the fixture feeds (Stamina, Strength) but the
|
||||
// 0x808e88 table prints Strength first (STR,AGI,STA,INT,SPI,HP,MANA).
|
||||
// Display order, not field order: `0x808e88` prints STR, AGI, STA, INT, SPI, HP, MANA.
|
||||
"[MOD_STRENGTH +9]",
|
||||
"[MOD_STAMINA +12]",
|
||||
"[RESIST_SINGLE +10 [SCHOOL5]]",
|
||||
@@ -58,7 +53,6 @@ fn item_line_law_and_red_requirements() {
|
||||
],
|
||||
"the verified line order (0276)"
|
||||
);
|
||||
// The name wears the Rare blue; the failed level line is red; the passed class line white.
|
||||
assert_eq!(lines[0].1, [0.0, 0.439, 0.867, 1.0], "rare-blue name");
|
||||
let class_line = &lines[10];
|
||||
assert_eq!(
|
||||
@@ -72,17 +66,13 @@ fn item_line_law_and_red_requirements() {
|
||||
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
|
||||
"level 25 < 37 → red"
|
||||
);
|
||||
// The right column of the slot|type pair carries the subclass.
|
||||
let ty: String = s.eval("return TTTextRight3:GetText()").unwrap();
|
||||
assert_eq!(ty, "Axe");
|
||||
// The speed column of the damage pair.
|
||||
let speed: String = s.eval("return TTTextRight4:GetText()").unwrap();
|
||||
assert_eq!(speed, "Speed 3.50");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The red law flips with player state: leveling past the requirement turns the line white; a
|
||||
/// class outside the list turns the class line red.
|
||||
#[test]
|
||||
fn red_lines_track_player_state() {
|
||||
let mut s = script();
|
||||
@@ -90,7 +80,7 @@ fn red_lines_track_player_state() {
|
||||
s.set_item_template(871, axe());
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 60,
|
||||
class_id: 8, // Mage — not in {Warrior, Rogue}
|
||||
class_id: 8, // Mage: not in {Warrior, Rogue}
|
||||
race_id: 1,
|
||||
skills: HashMap::new(),
|
||||
..Default::default()
|
||||
@@ -121,12 +111,10 @@ fn red_lines_track_player_state() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The families folded back 2026-07-10 (`0x52b650`): SIGNABLE green,
|
||||
/// UNIQUE before STARTS_QUEST, LOCKED red, six-equal resistances collapse to the ALL line (and
|
||||
/// Holy never prints singly), a known taught spell reds "Already known", the description gold —
|
||||
/// and NO openable line: `BenillaSetItemById` is a template source, and the whole openable/readable/
|
||||
/// creator tail rides the ref's item-OBJECT gate (`0x52e1c7`/`0x52e2e0` — byte-read 2026-07-20;
|
||||
/// the instance-tail law itself is in [`instance_tail_creator_and_readable`]).
|
||||
/// The gated families (`0x52b650`): SIGNABLE green, UNIQUE before STARTS_QUEST, LOCKED red, six
|
||||
/// equal resistances as one ALL line (Holy never prints alone), a known taught spell's red
|
||||
/// "Already known", the gold description, and no openable line: the openable, readable and creator
|
||||
/// tail needs an item object (`0x52e1c7`, `0x52e2e0`), and a template has none.
|
||||
#[test]
|
||||
fn verified_families_signable_locked_resists_known() {
|
||||
let mut s = script();
|
||||
@@ -146,7 +134,6 @@ fn verified_families_signable_locked_resists_known() {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// The player already knows the taught spell 2020.
|
||||
s.set_spellbook(SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![SpellSlotView {
|
||||
@@ -197,7 +184,6 @@ fn verified_families_signable_locked_resists_known() {
|
||||
[1.0, 210.0 / 255.0, 0.0, 1.0],
|
||||
"the description is the byte-verified gold"
|
||||
);
|
||||
// Holy exclusion: a lone Holy resist prints nothing.
|
||||
s.set_item_template(
|
||||
5519,
|
||||
ItemTemplateView {
|
||||
@@ -215,10 +201,8 @@ fn verified_families_signable_locked_resists_known() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// …and the five that DO print come out in the builder's own order, which is not the field
|
||||
// order: `0x52c8ad` runs `edi` 1..5 reading school `(edi == 1) ? 6 : edi`, so **Arcane leads**
|
||||
// and Holy is displaced rather than skipped (decision 2080 named this; we printed plain field
|
||||
// order, Fire first and Arcane last).
|
||||
// The five that print follow the builder, not the fields: `0x52c8ad` runs `edi` 1..5 reading
|
||||
// school `(edi == 1) ? 6 : edi`, so Arcane leads.
|
||||
s.set_item_template(
|
||||
5520,
|
||||
ItemTemplateView {
|
||||
@@ -246,13 +230,10 @@ fn verified_families_signable_locked_resists_known() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The slot|type line's independent cell reds (byte-read at the builder's coloring legs
|
||||
/// against the AddLine-CORE signature): a hard
|
||||
/// proficiency miss (`0xc4d4a0[class]` bit `1 << subclass`) reds the TYPE cell; the SLOT cell
|
||||
/// reds when the weapon is usable only via its alternate subclass (ItemSubClass
|
||||
/// prereq/postreq) or is an off-hand weapon without Dual Wield (`0x5eab70`). Plus the
|
||||
/// reputation-requirement red (`0xc0d390` while the player's standing is below the required
|
||||
/// rank) and the hidden-type suppression.
|
||||
/// The slot and type cells red separately: a hard proficiency miss (`0xc4d4a0[class]` bit
|
||||
/// `1 << subclass`) reds the type; usable only through the alternate subclass, or an off-hand
|
||||
/// weapon without Dual Wield (`0x5eab70`), reds the slot. The reputation line takes the red
|
||||
/// (`0xc0d390`) below the required rank, and a hidden subclass prints no type cell.
|
||||
#[test]
|
||||
fn proficiency_and_reputation_reds() {
|
||||
let mut s = script();
|
||||
@@ -306,8 +287,7 @@ fn proficiency_and_reputation_reds() {
|
||||
white,
|
||||
"met reputation is white"
|
||||
);
|
||||
// 1H-axes-only mask, no alternate on the view: the hard miss reds the TYPE cell and the
|
||||
// slot stays white. Friendly standing reds the rep line.
|
||||
// A 1H-axes mask, no alternate: the type cell reds, not the slot; Friendly reds the rep line.
|
||||
let mut req2 = req;
|
||||
req2.proficiency.insert(2, 1 << 0);
|
||||
req2.rep_ranks.insert(72, 4);
|
||||
@@ -326,8 +306,7 @@ fn proficiency_and_reputation_reds() {
|
||||
red,
|
||||
"unmet reputation is red"
|
||||
);
|
||||
// The same mask with the view's alternate resolved to 1H axes (the real (2,1) row's
|
||||
// prerequisite): usable-via-alternate reds the SLOT cell instead; the type goes white.
|
||||
// With the alternate set to 1H axes, as the real (2, 1) row has it, the slot reds instead.
|
||||
let mut alt = item;
|
||||
alt.proficiency_alt = Some(0);
|
||||
s.set_item_template(871, alt);
|
||||
@@ -344,8 +323,7 @@ fn proficiency_and_reputation_reds() {
|
||||
white,
|
||||
"the alternate covers the type"
|
||||
);
|
||||
// An off-hand weapon with its dagger bit set still reds the SLOT cell without Dual
|
||||
// Wield; learning it (an effect-40 spell) clears the red.
|
||||
// An off-hand dagger reds the slot until Dual Wield (an effect-40 spell) is known.
|
||||
s.set_item_template(
|
||||
872,
|
||||
ItemTemplateView {
|
||||
@@ -363,8 +341,7 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(872)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
// `INVTYPE_WEAPONOFFHAND`, NOT `INVTYPE_SHIELD` — one enUS sentence, two keys, and only the
|
||||
// weapon one belongs on an InventoryType 22.
|
||||
// Type 22 takes `INVTYPE_WEAPONOFFHAND`, not `INVTYPE_SHIELD`, though both read "Off Hand".
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_WEAPONOFFHAND]"),
|
||||
red,
|
||||
@@ -385,13 +362,12 @@ fn proficiency_and_reputation_reds() {
|
||||
white,
|
||||
"Dual Wield clears it"
|
||||
);
|
||||
// An item class with NO proficiency entry never reds (the map only ever holds classes
|
||||
// the server sent masks for).
|
||||
// A class with no proficiency entry never reds: the map holds only what the server sent.
|
||||
s.set_item_template(
|
||||
118,
|
||||
ItemTemplateView {
|
||||
name: "Tattered Cloth Vest".into(),
|
||||
class: 4, // armor — but req2 carries no class-4 entry
|
||||
class: 4, // armor, with no class-4 entry in req2
|
||||
subclass: 1, // cloth
|
||||
inventory_type: 5,
|
||||
..Default::default()
|
||||
@@ -405,7 +381,7 @@ fn proficiency_and_reputation_reds() {
|
||||
white,
|
||||
"a class with no mask entry stays white"
|
||||
);
|
||||
// A hidden subclass (displayFlags bit 0 — the Miscellaneous family) prints no type cell.
|
||||
// A hidden subclass (displayFlags bit 0, the Miscellaneous family) prints no type cell.
|
||||
s.set_item_template(
|
||||
889,
|
||||
ItemTemplateView {
|
||||
@@ -429,9 +405,8 @@ fn proficiency_and_reputation_reds() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// ITEM_MIN_LEVEL's `> 1` gate (byte-VERIFIED `0x52d2cf`: `cmp esi,0x1 / jle skip`): a level-1
|
||||
/// requirement — every starter consumable's shape (bread/water carry `RequiredLevel 1`) —
|
||||
/// prints NO line, exactly like no requirement at all; level 2 is the first that prints.
|
||||
/// `ITEM_MIN_LEVEL` prints only above 1 (`0x52d2cf`): a level-1 requirement, as starter food and
|
||||
/// water carry, prints nothing.
|
||||
#[test]
|
||||
fn required_level_one_is_hidden() {
|
||||
let mut s = script();
|
||||
@@ -463,28 +438,9 @@ fn required_level_one_is_hidden() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The instance tail (byte-read 2026-07-20, `0x52e1b1`–`0x52e358`): a REAL-instance hover
|
||||
/// (`SetBagItem`) appends the creator line — "Written by %s"
|
||||
/// (white) when the instance carries letter text, the green-escaped "<Made by %s>" otherwise —
|
||||
/// then READABLE off the instance text id (its template's PageText is 0 — the
|
||||
/// director-reported gap). An unresolved creator (name query in flight) emits no line. LOCKED
|
||||
/// yields to the instance UNLOCKED bit — which is also what un-gates ITEM_OPENABLE: a bag hover
|
||||
/// on an openable instance shows the green `<Right Click to Open>` (director-observed on a clam;
|
||||
/// the `p6` leg selector, `0x6e2ed0`, gates it).
|
||||
/// A **running cooldown** takes SetBagItem's other leg and suppresses the line — the two are
|
||||
/// structurally exclusive on this binding (decision 0896).
|
||||
/// **Line 3 — the charter's guild name and master**, between the NAME and the green
|
||||
/// `ITEM_SIGNABLE` (the builder `0x52b650`'s emission order: 2 NAME, 3 the petition
|
||||
/// triple, 4 SIGNABLE).
|
||||
///
|
||||
/// The director's report was that our charter tooltip showed the name and the green line with
|
||||
/// nothing between them, where the real client prints "Guild Name: BTC" / "Guild Master:
|
||||
/// Twowarrior". The ORDER is the half a text-only assertion would miss: these lines sit above the
|
||||
/// green one, not below it.
|
||||
///
|
||||
/// Also pinned: the two key families (a plain petition reads "Petition:" / "Created by"), and that
|
||||
/// an unresolved owner withholds only its own line — the creator line's rule, and the reason a
|
||||
/// first hover of an unopened charter is not blank but partial.
|
||||
/// A charter's guild name and master sit between the item name and the green `ITEM_SIGNABLE`
|
||||
/// (`0x52b650` emits the name, the petition lines, then SIGNABLE). A plain petition takes the
|
||||
/// "Petition:" and "Created by" keys, and an owner still in flight withholds only its own line.
|
||||
#[test]
|
||||
fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
let mut s = script();
|
||||
@@ -494,7 +450,7 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
ItemTemplateView {
|
||||
name: "Guild Charter".into(),
|
||||
quality: 1,
|
||||
flags: 0x2000, // ITEM_FLAG_CHARTER — the green line's own gate
|
||||
flags: 0x2000, // ITEM_FLAG_CHARTER, the green line's own gate
|
||||
max_count: 1,
|
||||
bonding: 1,
|
||||
..Default::default()
|
||||
@@ -517,7 +473,6 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
owner: Some("Twowarrior".into()),
|
||||
})),
|
||||
);
|
||||
// The owner's name still in flight — the title line stands, its own does not.
|
||||
slots.insert(
|
||||
2,
|
||||
charter(Some(PetitionSlotView {
|
||||
@@ -526,9 +481,7 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
owner: None,
|
||||
})),
|
||||
);
|
||||
// No record yet at all: the plate is the name and the green line, as it was before this.
|
||||
slots.insert(3, charter(None));
|
||||
// A non-charter petition takes the OTHER key family.
|
||||
slots.insert(
|
||||
4,
|
||||
charter(Some(PetitionSlotView {
|
||||
@@ -663,9 +616,6 @@ fn instance_tail_creator_and_readable() {
|
||||
slots.insert(3, slot(2589, false, Some("Geoffrey"), 0)); // crafted, still locked
|
||||
slots.insert(4, slot(2589, false, None, 0x4)); // unlocked → LOCKED gone, open line on
|
||||
slots.insert(5, slot(7973, false, None, 0)); // the clam: lockless, openable outright
|
||||
// The same clam with a RUNNING cooldown — `SetBagItem`'s p6=1 leg, which skips the openable
|
||||
// tree entirely (the ITEM_COOLDOWN_TIME line takes its place in the reference; that line has
|
||||
// no feed here yet, so this slot renders bare).
|
||||
slots.insert(
|
||||
6,
|
||||
ContainerSlot {
|
||||
@@ -698,12 +648,12 @@ fn instance_tail_creator_and_readable() {
|
||||
);
|
||||
assert_eq!(lines[1].1, [1.0, 1.0, 1.0, 1.0], "WRITTEN_BY is white");
|
||||
assert_eq!(lines[2].1, [0.0, 1.0, 0.0, 1.0], "READABLE is green");
|
||||
// Creator unresolved → no writer line, READABLE stays (the re-push repaints later).
|
||||
// The instance tail (`0x52e1b1`-`0x52e358`): a creator still in flight prints no writer line,
|
||||
// and READABLE stays.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 2)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Plain Letter", "[ITEM_READABLE]"]);
|
||||
// Crafted + locked: the green-escaped Made-by; no open line while LockID gates it.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 3)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
@@ -716,22 +666,19 @@ fn instance_tail_creator_and_readable() {
|
||||
],
|
||||
"CREATED_BY carries the string's own green escape; locked chest hides OPENABLE"
|
||||
);
|
||||
// The instance UNLOCKED bit retires the LOCKED line AND satisfies the openable lock sub-gate.
|
||||
// The instance's UNLOCKED bit drops the LOCKED line and satisfies the openable lock gate.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 4)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(texts, vec!["Heavy Chest", "[ITEM_OPENABLE]"]);
|
||||
assert_eq!(lines[1].1, [0.0, 1.0, 0.0, 1.0], "OPENABLE is green");
|
||||
// The director's case: a lockless LOOTABLE template (a clam) is openable outright — name +
|
||||
// the green line, nothing between them.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 5)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "[ITEM_OPENABLE]"]);
|
||||
// The same clam mid-cooldown takes SetBagItem's OTHER leg (p6=1) — the openable tree is
|
||||
// skipped wholesale, so the green line is gone (the reference prints ITEM_COOLDOWN_TIME in
|
||||
// its place; unfed here). `hasCooldown`, the binding's own return, is the same boolean.
|
||||
// Mid-cooldown the clam takes `SetBagItem`'s other leg (p6 = 1, picked at `0x6e2ed0`), which
|
||||
// skips the openable tree; the reference prints `ITEM_COOLDOWN_TIME` there, unfed here.
|
||||
let has_cd: bool = s
|
||||
.eval(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); return TT:SetBagItem(0, 6)"#)
|
||||
.unwrap();
|
||||
@@ -745,25 +692,15 @@ fn instance_tail_creator_and_readable() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **The bind line's Soulbound override** — the bind line reads the INSTANCE, not only the template
|
||||
/// (an equipped Maiden's Circle reads *Soulbound*, not *Binds when equipped*; equipped pants
|
||||
/// likewise, not *Binds when picked up*).
|
||||
///
|
||||
/// The reference's rule: Bonding `[record+0x194]` ∈ {1..5}
|
||||
/// decides whether a line prints at all; a **runtime-bound instance** (`0x5da2c0` — soulbound
|
||||
/// flag, or a live enchant slot that binds) overrides it to `ITEM_SOULBOUND`, and to
|
||||
/// `ITEM_BIND_QUEST` for the quest kinds; only then does the jump table `0x52e4fc` pick
|
||||
/// 1→picked up · 2→equipped · 3→used · 4/5→Quest Item.
|
||||
///
|
||||
/// Four claims, three of them controls: the override fires on a bound instance, an UNbound
|
||||
/// instance of the same item still reads *Binds when equipped*, a template hover (no instance at
|
||||
/// all) still reads *Binds when equipped*, and a bound QUEST item stays *Quest Item* — the
|
||||
/// override's other arm is the same text 4|5 print anyway.
|
||||
/// Bonding `[record+0x194]` in 1..5 decides whether a bind line prints; a runtime-bound instance
|
||||
/// (`0x5da2c0`: the soulbound flag, or a live enchant slot that binds) prints `ITEM_SOULBOUND`,
|
||||
/// or `ITEM_BIND_QUEST` for the quest kinds; else the jump table `0x52e4fc` picks 1 picked up,
|
||||
/// 2 equipped, 3 used, 4 and 5 Quest Item.
|
||||
#[test]
|
||||
fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_item_template(871, axe()); // bonding 2 — Binds when equipped
|
||||
s.set_item_template(871, axe()); // bonding 2: Binds when equipped
|
||||
let ring = ItemTemplateView {
|
||||
name: "Maiden's Circle".into(),
|
||||
quality: 2,
|
||||
@@ -789,9 +726,9 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
..Default::default()
|
||||
};
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(1, slot(942, true)); // bound: the reported case
|
||||
slots.insert(1, slot(942, true)); // bound
|
||||
slots.insert(2, slot(942, false)); // the same ring, not yet bound
|
||||
slots.insert(3, slot(4913, true)); // a bound QUEST item
|
||||
slots.insert(3, slot(4913, true)); // a bound quest item
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
@@ -831,25 +768,22 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
);
|
||||
assert_eq!(color, [1.0, 1.0, 1.0, 1.0], "the bind line is white");
|
||||
|
||||
// Control 1: the SAME item, instance not bound — the template's bonding stands.
|
||||
// Controls: the same ring unbound, and a template hover, which has no instance.
|
||||
s.run(r#"TT:SetBagItem(0, 2)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 2: a TEMPLATE hover carries no instance at all, so nothing can override.
|
||||
s.run(r#"TT:BenillaSetItemById(871)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 3: the override's other arm is ITEM_BIND_QUEST — the same text 4|5 already print.
|
||||
// A bound quest item stays `ITEM_BIND_QUEST`, the text 4 and 5 print anyway.
|
||||
s.run(r#"TT:SetBagItem(0, 3)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_QUEST]");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The damage block's five-arm template matrix** (the loop at `0x52c22b`; decision 2080 named
|
||||
/// this cell and 2158 converted it). Each arm here names the leg it takes: the school predicate is
|
||||
/// the slot's school NUMBER, the ammo predicate is `ItemClass == 6` alone, the single predicate
|
||||
/// compares the two ROUNDED bounds, and the first/`PLUS_` flag is per-item — a skipped slot does
|
||||
/// not consume it. The rounding is `floor(min)` / `ceil(max)`, not a round-half pair.
|
||||
/// The damage templates (`0x52c22b`): the school arm goes by the slot's school number, the ammo
|
||||
/// arm by `ItemClass == 6` alone, the single arm by equal rounded bounds, and the first/`PLUS_`
|
||||
/// flag is per item, kept past a skipped slot. Bounds round as `floor(min)`, `ceil(max)`.
|
||||
#[test]
|
||||
fn damage_matrix_arms_and_the_first_flag() {
|
||||
let mut s = script();
|
||||
@@ -875,9 +809,7 @@ fn damage_matrix_arms_and_the_first_flag() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The plain two-number arm, the SKIP, and the PLUS_ twin — the skipped middle slot does not
|
||||
// consume the first flag, so slot 2 still prints as a later line, and only the FIRST emitted
|
||||
// line carries the Speed cell.
|
||||
// A zero middle slot is skipped; only the first line carries the Speed cell.
|
||||
let lines = show(
|
||||
&mut s,
|
||||
900,
|
||||
@@ -898,29 +830,24 @@ fn damage_matrix_arms_and_the_first_flag() {
|
||||
.expect("right cell 4");
|
||||
assert_eq!(later, "", "a later damage line renders left-only");
|
||||
|
||||
// floor(min) / ceil(max) on fractional bounds — Fang of the Mystics' real numbers. A
|
||||
// round-half pair would say "39 - 86"; the biased pair says 38.
|
||||
// Fang of the Mystics' real bounds: `floor`/`ceil` give 38 - 86, where rounding gives 39.
|
||||
let lines = show(&mut s, 901, weapon(vec![(38.7, 85.7, 0)]));
|
||||
assert_eq!(lines[2], "[DMG 38 - 86]", "{lines:?}");
|
||||
|
||||
// The SINGLE arm — the two ROUNDED bounds equal. It is a no-school, non-ammo leaf only.
|
||||
// The single arm: equal rounded bounds, no school, not ammo.
|
||||
let lines = show(&mut s, 902, weapon(vec![(7.0, 7.0, 0)]));
|
||||
assert_eq!(lines[2], "[DMG1 7]", "{lines:?}");
|
||||
// …and a school'd slot with equal bounds still takes the WITH_SCHOOL arm, two identical
|
||||
// numbers and all: there is no SINGLE_…_WITH_SCHOOL template.
|
||||
// A slot with a school and equal bounds takes `WITH_SCHOOL`: no single school template exists.
|
||||
let lines = show(&mut s, 903, weapon(vec![(7.0, 7.0, 4)]));
|
||||
assert_eq!(lines[2], "[DMGS 7 - 7 [SCHOOL4]]", "{lines:?}");
|
||||
|
||||
// AMMO is `ItemClass == 6` alone. The `%g` value is the two rounded bounds averaged and is
|
||||
// NOT divided by anything — Rough Arrow's 1–2 reads 1.5 — and ammo gets neither a Speed
|
||||
// cell nor a DPS line, because both gate on class 2.
|
||||
// Ammo's `%g` is the rounded bounds' average, undivided (Rough Arrow's 1-2 reads 1.5), with
|
||||
// no Speed cell or DPS line, both gated on class 2.
|
||||
let arrow = ItemTemplateView {
|
||||
name: "Rough Arrow".into(),
|
||||
class: 6,
|
||||
subclass: 2,
|
||||
sub_class_display: Some("Arrow".into()),
|
||||
// `ItemClass.dbc` row 6's own name — the LEFT cell for a class-6 item, which never
|
||||
// consults the InventoryType key table.
|
||||
item_type: Some("Projectile".into()),
|
||||
inventory_type: 24,
|
||||
damages: vec![(1.0, 2.0, 0)],
|
||||
@@ -928,9 +855,8 @@ fn damage_matrix_arms_and_the_first_flag() {
|
||||
..Default::default()
|
||||
};
|
||||
let lines = show(&mut s, 904, arrow.clone());
|
||||
// The slot|type line above it reads "Projectile | Arrow": a class-6 item takes
|
||||
// `ItemClass.dbc`'s own row-6 name on the LEFT (`0x52c0bc`, read verbatim — never a
|
||||
// GlobalString), not the empty `INVTYPE_AMMO` key.
|
||||
// The type line reads "Projectile | Arrow": the left cell is the DBC name (`0x52c0bc`),
|
||||
// never a GlobalString such as the absent `INVTYPE_AMMO`.
|
||||
assert_eq!(lines[1], "Projectile", "{lines:?}");
|
||||
let ty: String = s
|
||||
.eval("return TTTextRight2:GetText() or ''")
|
||||
@@ -984,9 +910,8 @@ fn damage_matrix_arms_and_the_first_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The bag line** (`0x52b754`): its gate is `InventoryType == 0x12` alone — never the
|
||||
/// slot count — and its second hole is the same `ItemSubClass` DisplayName the type cell reads, so
|
||||
/// the noun is per-subclass. A container whose row names nothing prints no slot line at all.
|
||||
/// The bag line (`0x52b754`) gates on `InventoryType == 0x12` alone, and its noun is the
|
||||
/// subclass's DisplayName; a row with no name prints no line.
|
||||
#[test]
|
||||
fn container_slots_line_names_its_subclass() {
|
||||
let mut s = script();
|
||||
@@ -1020,17 +945,15 @@ fn container_slots_line_names_its_subclass() {
|
||||
"[SLOTS 24 Soul Bag]",
|
||||
"the noun is the subclass's, not a constant"
|
||||
);
|
||||
// A quiver is InventoryType 18 like every other container — INVTYPE_QUIVER is a dead slot
|
||||
// name in 1.12 — so it reaches this line and names itself.
|
||||
// A quiver is InventoryType 18 like any container, so it names itself here.
|
||||
let mut quiver = bag(2, Some("Quiver"), 8);
|
||||
quiver.class = 11;
|
||||
assert_eq!(show(&mut s, 912, quiver)[1], "[SLOTS 8 Quiver]");
|
||||
// The gate never tests the slot count.
|
||||
assert_eq!(
|
||||
show(&mut s, 913, bag(0, Some("Bag"), 0))[1],
|
||||
"[SLOTS 0 Bag]"
|
||||
);
|
||||
// No row name: NEITHER this line nor the ordinary slot|type one.
|
||||
// No row name: neither this line nor the slot and type line.
|
||||
let lines = show(&mut s, 914, bag(0, None, 16));
|
||||
assert_eq!(lines.len(), 1, "name line only: {lines:?}");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
//! Render mechanics around the line law: the sell-price money protocol (OnTooltipAddMoney +
|
||||
//! ITEM_UNSELLABLE), wrapped-line measurement through the re-enter loop, and the unit health
|
||||
//! bar hiding on an item render.
|
||||
//! Render mechanics around the line order: sell price, wrapped lines, the unit health bar,
|
||||
//! durability, enchant lines and random-property rolls.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// The sell-price money protocol: SetBagItem at an open merchant fires OnTooltipAddMoney with
|
||||
/// SellPrice × stack; price 0 prints the ITEM_UNSELLABLE line; no merchant → neither; the
|
||||
/// in-flight template renders the link-name fallback and records the ask.
|
||||
/// `SetBagItem` at an open merchant fires `OnTooltipAddMoney` with SellPrice × stack, and price 0
|
||||
/// prints `ITEM_UNSELLABLE`; with no merchant, neither. A template in flight renders the link name.
|
||||
#[test]
|
||||
fn bag_item_money_law_and_fallback() {
|
||||
let mut s = script();
|
||||
@@ -80,7 +78,6 @@ fn bag_item_money_law_and_fallback() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.take_item_stat_asks(), vec![9999], "the miss asked");
|
||||
// Zero sell price at a merchant: the ITEM_UNSELLABLE line, no money.
|
||||
s.set_item_template(
|
||||
9999,
|
||||
ItemTemplateView {
|
||||
@@ -104,13 +101,8 @@ fn bag_item_money_law_and_fallback() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A wrap-flagged line carries its wrap column on the VERY FIRST measure ask (append_line pins
|
||||
/// the region width at write time), so ONE round-trip answers the wrapped extent — and the
|
||||
/// plate height/width converge even when the hover's re-enter loop clears + rebuilds the
|
||||
/// content every frame. The old two-step (layout re-pinning after an overflowing unwrapped
|
||||
/// measure) never converged under that loop: the wrapped re-measure was wiped before it could
|
||||
/// land, wrap lines contributed no height, and everything after them spilled below the plate
|
||||
/// (the live bread/hearthstone bug).
|
||||
/// A wrap line's first measure asks with the wrap column, so one pass answers the wrapped size,
|
||||
/// and the size holds while the hover clears and rebuilds the content every frame.
|
||||
#[test]
|
||||
fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
let mut s = script();
|
||||
@@ -137,8 +129,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// ONE measure pass, the frame loop's shape: the wrap-flagged description asks WITH the
|
||||
// wrap column; plain lines ask unconstrained.
|
||||
// One measure pass: the wrap-flagged description asks with the wrap column, others without.
|
||||
let answer = |s: &mut UiScript| {
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
let answers: Vec<(u32, f32, f32, u64)> = reqs
|
||||
@@ -168,8 +159,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The re-enter loop: clear + rebuild + one measure pass per frame, three frames — the size
|
||||
// must HOLD (the oscillation regression).
|
||||
// Clear, rebuild and measure once per frame for three frames: the size holds.
|
||||
for _ in 0..3 {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -191,8 +181,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The mouseover health bar is UNIT content: an item render on the same tooltip HIDES it (the
|
||||
/// live bug: the bar from the last mob hover rode under every item tooltip).
|
||||
/// The mouseover health bar is unit content: an item render on the same tooltip hides it.
|
||||
#[test]
|
||||
fn item_render_hides_the_unit_health_bar() {
|
||||
let mut s = script();
|
||||
@@ -241,9 +230,8 @@ fn item_render_hides_the_unit_health_bar() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A REAL-INSTANCE hover renders the instance's LIVE durability pair, not the template's full
|
||||
/// max/max — the wire updates `ITEM_FIELD_DURABILITY` on damage (death 10%, spirit healer 25%;
|
||||
/// director-reported: the 25% loss showed nowhere). A plain template/link hover keeps max/max.
|
||||
/// A bag hover shows the instance's live `ITEM_FIELD_DURABILITY` (a death costs 10%, the spirit
|
||||
/// healer 25%); a template or link hover shows max/max.
|
||||
#[test]
|
||||
fn real_instance_hover_renders_live_durability() {
|
||||
let mut s = script();
|
||||
@@ -302,10 +290,8 @@ fn real_instance_hover_renders_live_durability() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// FULLY BROKEN gear renders its true `0 / max` pair (director-caught: gear ground to durability
|
||||
/// 0 by repeated spirit rezzes read as 100% — the create block omits the zero `DURABILITY` field,
|
||||
/// and the sparse `None` fell back to the template's max/max; `ObjectFields`' created semantics
|
||||
/// now feed the explicit 0 through).
|
||||
/// Broken gear shows its true `0 / max`: a create block omits a zero `DURABILITY`, which must not
|
||||
/// read as the template's max.
|
||||
#[test]
|
||||
fn broken_instance_hover_renders_zero_durability() {
|
||||
let mut s = script();
|
||||
@@ -353,8 +339,7 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// Red iff broken (`0x854bc4`, colour `0xc0d390`): the 0/40
|
||||
// line paints the requirement red, never white.
|
||||
// Red only when broken (`0x854bc4`, colour `0xc0d390`).
|
||||
let lines = super::lines_of(&mut s);
|
||||
let dur = lines
|
||||
.iter()
|
||||
@@ -367,12 +352,8 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The **enchant lines** (law line 17, decision 0915) — the director's report: an axe carrying
|
||||
/// Enchant Weapon - Agility showed its green glow in the world and said nothing in the tooltip.
|
||||
///
|
||||
/// Three claims at once: the line renders from the instance's resolved enchant text, it sits
|
||||
/// **between the resistances and the durability line** (the law's 16 → 17 → 18), and it is green.
|
||||
/// The control is the template/link hover of the same item — no instance, no enchant, no line.
|
||||
/// An instance's enchant prints from its resolved text, in green, between the resistances and
|
||||
/// durability; a template hover of the same item has no instance and so no enchant line.
|
||||
#[test]
|
||||
fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
let mut s = script();
|
||||
@@ -384,8 +365,7 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
quality: Some(4),
|
||||
item_id: 22816,
|
||||
durability: Some((105, 105)),
|
||||
// What the app resolved from `ITEM_FIELD_ENCHANTMENT` slot 0 → enchant 2564 →
|
||||
// `SpellItemEnchantment`'s own name string. Slot 0 = permanent, positive id.
|
||||
// `ITEM_FIELD_ENCHANTMENT` slot 0 (permanent) → enchant 2564 → its name string.
|
||||
enchants: vec![EnchantView {
|
||||
slot: 0,
|
||||
name: "Agility +15".into(),
|
||||
@@ -441,7 +421,6 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
"the enchant line is green"
|
||||
);
|
||||
|
||||
// The control: the same item as a TEMPLATE hover has no instance, so no enchant line.
|
||||
s.run(r#"TT:BenillaSetItemById(22816)"#).unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
assert!(
|
||||
@@ -451,11 +430,8 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The **enchant colour bands** (`0x52ca29`, decision 0920). The colour is per SLOT, not per
|
||||
/// family: only slots 0 (permanent) and 1 (temporary) are ever coloured — green for a positive id,
|
||||
/// the tooltip's OTHER red (`0xc0d398 = ffff0000`, distinct from the requirement lines' `ffff2020`)
|
||||
/// for a negative one — and the random-property slots 2..6 are always white whatever the sign. Our
|
||||
/// first cut painted every slot green.
|
||||
/// Enchant colour goes by slot (`0x52ca29`): slots 0 and 1 are green for a positive id and pure
|
||||
/// red (`0xc0d398`, `ffff0000`, not the requirement `ffff2020`) for a negative one; 2..6 are white.
|
||||
#[test]
|
||||
fn enchant_line_colour_is_per_slot_and_sign() {
|
||||
let mut s = script();
|
||||
@@ -483,7 +459,6 @@ fn enchant_line_colour_is_per_slot_and_sign() {
|
||||
name: "Stamina +7".into(),
|
||||
..Default::default()
|
||||
},
|
||||
// A suffix slot with a NEGATIVE id is still white — the band, not the sign, rules.
|
||||
EnchantView {
|
||||
slot: 4,
|
||||
name: "Spirit +3".into(),
|
||||
@@ -546,10 +521,8 @@ fn enchant_line_colour_is_per_slot_and_sign() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The temporary enchant's countdown REPLACES the name in the same line (never a second one), and
|
||||
/// the charges suffix rides after it — the `0x52fa50` bucket ladder and the `" (%s)"` join. The
|
||||
/// countdown's source is `SMSG_ITEM_ENCHANT_TIME_UPDATE`, so a slot with no packet shows the bare
|
||||
/// name: that is the control here.
|
||||
/// A temporary enchant prints one line, its countdown template (`0x52fa50`) around the name, then
|
||||
/// the charges joined by `" (%s)"`; with no `SMSG_ITEM_ENCHANT_TIME_UPDATE` it is the bare name.
|
||||
#[test]
|
||||
fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
let mut s = script();
|
||||
@@ -615,15 +588,11 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The loot hover shows the ROLL, never the placeholder** (decision 1547). `SetLootItem 0x533470`
|
||||
/// writes an instance block (p6=1) whose `+0x424` is the wire's randomPropertyId and whose seven
|
||||
/// enchant slots are zeroed, and passes an all-zero item GUID — so there is never an object, the
|
||||
/// builder takes the suffix-row copy (`0x52b7e0`) into slots 2..6, and the `ITEM_RANDOM_ENCHANT`
|
||||
/// arm is unreachable (the entry fork, `0x52c991`). The row's name is the suffix-joined one
|
||||
/// (`0x5d8b00`, the same string `GetLootSlotInfo` returns), so the plate reads "… of the Monkey".
|
||||
///
|
||||
/// This is the reported bug's exact shape: through the template path (`BenillaSetItemById`) the same
|
||||
/// hover printed the ITEM_RANDOM_ENCHANT placeholder until the item reached a bag.
|
||||
/// A loot hover shows the roll, never the placeholder: `SetLootItem` (`0x533470`) builds an
|
||||
/// instance block (p6 = 1) with the roll at `+0x424`, zeroed enchant slots and no object, so the
|
||||
/// builder copies the suffix row into slots 2..6 (`0x52b7e0`) and never reaches
|
||||
/// `ITEM_RANDOM_ENCHANT` (`0x52c991`). The name is the suffixed one `GetLootSlotInfo` returns
|
||||
/// (`0x5d8b00`).
|
||||
#[test]
|
||||
fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -633,8 +602,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
name: "Bloodrazor".into(),
|
||||
quality: 2,
|
||||
class: 2,
|
||||
// The template CAN roll — the placeholder's own gate, so its absence below is the law
|
||||
// and not an accident of the fixture.
|
||||
// The template can roll, so the placeholder's absence below is the rule at work.
|
||||
random_property: 42,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -649,8 +617,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
is_coin: false,
|
||||
item_id: 8888,
|
||||
link: Some("|cff1eff00|Hitem:8888:0:584:0|h[Bloodrazor of the Monkey]|h|r".into()),
|
||||
// The row carries the ROLL, not resolved lines — the engine resolves it, like the
|
||||
// reference reading its own DBC store off the block's `+0x424`.
|
||||
// The row carries the roll id; the engine resolves it, as the reference reads `+0x424`.
|
||||
random_property_id: 584,
|
||||
})],
|
||||
fishing: false,
|
||||
@@ -702,15 +669,12 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
.iter()
|
||||
.find(|(t, _)| t == line)
|
||||
.unwrap_or_else(|| panic!("the roll's slot line {line} — got {texts:?}"));
|
||||
// Slots 2..6 are always WHITE, whatever the sign (`0x52ca29`).
|
||||
assert_eq!(l.1, [1.0, 1.0, 1.0, 1.0], "{line} is white");
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of the same law: a loot row with **no** roll shows no enchant line AND no
|
||||
/// placeholder, even though the template can roll. The fork tests the block's presence, not its
|
||||
/// contents (`0x52c9a3`) — which is why this cannot be expressed as "print the placeholder when the
|
||||
/// slots are empty".
|
||||
/// A loot row with no roll shows neither enchant lines nor the placeholder, though the template can
|
||||
/// roll: the fork tests the block's presence, not its contents (`0x52c9a3`).
|
||||
#[test]
|
||||
fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -754,11 +718,8 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A **chat link** is a block source too (`SetHyperlink 0x532181`, p6=1): its `|Hitem:` token 2 is
|
||||
/// the roll, which `0x52b7e0` expands into slots 2..6, and the placeholder arm is unreachable.
|
||||
/// Decision 0920's prose said a hyperlink hover shows the placeholder; `0x52c9a3` says otherwise,
|
||||
/// and this is the case that proves it — the same drop, linked in chat instead of hovered in the
|
||||
/// loot window, must read identically.
|
||||
/// A chat link is a block source too (`SetHyperlink` `0x532181`, p6 = 1): its `|Hitem:` token 2 is
|
||||
/// the roll, `0x52b7e0` expands it into slots 2..6, and `0x52c9a3` never reaches the placeholder.
|
||||
#[test]
|
||||
fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -815,10 +776,8 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ITEM_RANDOM_ENCHANT` (`0x52cc33`) — the template-only placeholder: a random-property item with
|
||||
/// NO instance to read a roll from prints it, green, and the per-slot lines and this one are
|
||||
/// mutually exclusive by construction. The control is the same template hovered as a real enchanted
|
||||
/// instance: the roll is known, so the placeholder gives way to the slot lines.
|
||||
/// `ITEM_RANDOM_ENCHANT` (`0x52cc33`) prints, green, only for a random-property item with no
|
||||
/// instance to read a roll from; an instance's known roll prints its slot lines instead.
|
||||
#[test]
|
||||
fn random_property_template_hover_shows_the_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -848,7 +807,6 @@ fn random_property_template_hover_shows_the_placeholder() {
|
||||
.expect("the template hover shows the placeholder");
|
||||
assert_eq!(placeholder.1, [0.0, 1.0, 0.0, 1.0], "green");
|
||||
|
||||
// With a real instance (the roll is known) the placeholder is gone and the slots print.
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
//! The engine item-tooltip renderer (decision 0274 P1, law per 0276): the verified line law
|
||||
//! end-to-end, the red failed-requirement law against live player state, the sell-price money
|
||||
//! protocol (engine fires `OnTooltipAddMoney` — merchant open + real instance + repair off),
|
||||
//! and the in-flight-template fallback.
|
||||
//! The engine item-tooltip renderer: line order, requirement reds against live player state, the
|
||||
//! sell price (`OnTooltipAddMoney` at an open merchant, for a real instance, out of repair mode)
|
||||
//! and the fallback for a template still in flight.
|
||||
|
||||
use super::common::script as bare_script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in `GlobalStrings.lua` for the item builder — **deliberately not the shipped wording**.
|
||||
///
|
||||
/// Every sentence the builder draws is a key resolved off the VM's own globals (decision 2045),
|
||||
/// and what these tests establish is *which key* each line reaches and what fills it, never what
|
||||
/// the sentence says. Naming each value after its own key is the point: `INVTYPE_SHIELD`,
|
||||
/// `INVTYPE_WEAPONOFFHAND` and `SECONDARYHANDSLOT` all read "Off Hand" in enUS, `ITEM_REQ_SKILL`
|
||||
/// shares "Requires %s" with the three `LOCKED_WITH_*` keys, and an assertion on the English
|
||||
/// would pass on every one of them. Read an expected line here as "this key, filled with these".
|
||||
///
|
||||
/// The values keep each shipped template's *hole shape* — that is what proves the fill — and
|
||||
/// nothing else about it.
|
||||
/// Stand-in `GlobalStrings.lua` values, each named after its key with the shipped hole shape:
|
||||
/// several keys share one enUS sentence ("Off Hand", "Requires %s"), so the tests assert which key
|
||||
/// each line reaches and what fills it.
|
||||
fn seed_item_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -121,8 +112,7 @@ fn seed_item_strings(s: &mut UiScript) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The shared fixture VM, with [`seed_item_strings`] already run — every test in this tree needs
|
||||
/// it, because a builder that resolves nothing draws nothing.
|
||||
/// The fixture VM, with [`seed_item_strings`] run.
|
||||
fn script() -> UiScript {
|
||||
let mut s = bare_script();
|
||||
seed_item_strings(&mut s);
|
||||
@@ -136,8 +126,7 @@ fn axe() -> ItemTemplateView {
|
||||
quality: 3,
|
||||
class: 2,
|
||||
subclass: 1,
|
||||
// ItemSubClass.dbc's DisplayName for (2, 1) — the app-resolved word the type cell
|
||||
// prints; the two axe subclasses share it.
|
||||
// `ItemSubClass.dbc`'s DisplayName for (2, 1), which both axe subclasses share.
|
||||
sub_class_display: Some("Axe".into()),
|
||||
inventory_type: 17,
|
||||
bonding: 2,
|
||||
@@ -155,8 +144,7 @@ fn axe() -> ItemTemplateView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tooltip's left-column lines in order, each with its draw color (read from the extract —
|
||||
/// the color a renderer would actually paint).
|
||||
/// The tooltip's left-column lines in order, each with the colour the extract paints it.
|
||||
fn lines_of(s: &mut UiScript) -> Vec<(String, [f32; 4])> {
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
//! The item-SET block (`ITEM_SET_NAME 0x854b1c`): order (blank, gold header, skill line, member
|
||||
//! ladder, blank, bonuses), the threshold-ascending sort, the skill gate, member cream/gray, and
|
||||
//! the ask-once for unseen set ids.
|
||||
//! The item-set block (`ITEM_SET_NAME` `0x854b1c`): a blank, the gold header, the skill line, the
|
||||
//! member ladder, a blank, then the bonuses by ascending threshold; and the quest reward hovers.
|
||||
|
||||
use super::{lines_of, script};
|
||||
use crate::script::*;
|
||||
|
||||
/// The SET block, byte-read: a blank gold line, the gold `ITEM_SET_NAME` header, the skill
|
||||
/// line (white/red) between header and the member ladder (cream when equipped / gray; in-flight
|
||||
/// names wait), a second blank, then the threshold bonuses SORTED ascending — green only when the
|
||||
/// skill requirement is met AND owned ≥ threshold — plus the ask-once for an unseen set id.
|
||||
///
|
||||
/// **The two bonus arms are two different keys** (`0x52e056..0x52e0d6`): an ACTIVE bonus formats
|
||||
/// `ITEM_SET_BONUS` = "Set: %s" with no count at all, and only an inactive one formats
|
||||
/// `ITEM_SET_BONUS_GRAY` = "(%d) Set: %s". Watching the (2) bonus move between them as the skill
|
||||
/// gate opens and closes is what these assertions are for.
|
||||
/// Members are cream when equipped and gray otherwise; a bonus is green only with the skill met
|
||||
/// and enough owned. The bonus arms are two keys (`0x52e056..0x52e0d6`): an active bonus formats
|
||||
/// `ITEM_SET_BONUS` ("Set: %s", no count), an inactive one `ITEM_SET_BONUS_GRAY` ("(%d) Set: %s").
|
||||
#[test]
|
||||
fn item_set_block_counts_and_colors() {
|
||||
let mut s = script();
|
||||
@@ -29,7 +22,7 @@ fn item_set_block_counts_and_colors() {
|
||||
};
|
||||
vest.stats.clear();
|
||||
s.set_item_template(6303, vest);
|
||||
// Two members equipped (the chest itself + the belt).
|
||||
// Two members equipped: the chest itself and the belt.
|
||||
let mut slots: InventorySlots = Default::default();
|
||||
slots[4] = Some(InvSlotView {
|
||||
item_id: 6303,
|
||||
@@ -46,11 +39,10 @@ fn item_set_block_counts_and_colors() {
|
||||
(6303, Some("Defias Mark".into())),
|
||||
(6304, Some("Defias Belt".into())),
|
||||
(6305, Some("Defias Gloves".into())),
|
||||
(6306, None), // template still in flight — no line yet
|
||||
(6306, None), // template still in flight: no line yet
|
||||
(6307, Some("Defias Boots".into())),
|
||||
],
|
||||
// Stored high-first (the catalog keeps DBC slot order — The Gladiator ships
|
||||
// 3,2,5,4): the renderer must sort ascending at print time.
|
||||
// Stored high-first in DBC slot order (The Gladiator ships 3,2,5,4); the renderer sorts.
|
||||
bonuses: vec![
|
||||
(4, "+10 Attack Power.".into()),
|
||||
(2, "Increases movement speed slightly.".into()),
|
||||
@@ -120,8 +112,7 @@ fn item_set_block_counts_and_colors() {
|
||||
gray,
|
||||
"inactive bonus is gray"
|
||||
);
|
||||
// The skill gate (`0x5eaae0`): an unmet set-level skill reds its line — seated between
|
||||
// the header and the first member — and grays every bonus, met thresholds included.
|
||||
// The skill gate (`0x5eaae0`): an unmet skill reds its line and grays every bonus.
|
||||
let mut gated = set_view;
|
||||
gated.required_skill = 165;
|
||||
gated.required_skill_rank = 250;
|
||||
@@ -138,7 +129,7 @@ fn item_set_block_counts_and_colors() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "[MIN_SKILL Leatherworking 250]"), red);
|
||||
// The gate also moves it onto the OTHER key: an inactive bonus is the counted spelling.
|
||||
// Grayed, the (2) bonus takes the counted key.
|
||||
assert_eq!(
|
||||
color(
|
||||
&lines,
|
||||
@@ -162,7 +153,6 @@ fn item_set_block_counts_and_colors() {
|
||||
green,
|
||||
"meeting the skill restores the green — and the countless key with it"
|
||||
);
|
||||
// An unseen set id records the ask-once.
|
||||
s.set_item_template(
|
||||
9999,
|
||||
ItemTemplateView {
|
||||
@@ -177,10 +167,8 @@ fn item_set_block_counts_and_colors() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `GameTooltip:SetQuestItem(type, index)` and `SetQuestLogItem(type, index)` — the quest-giver
|
||||
/// panels' and the quest log's reward hovers (stock QuestFrameTemplates.xml:148,
|
||||
/// QuestLogFrame.xml:113): the row's item by id through the shared renderer; a row that is not
|
||||
/// there leaves the tooltip empty (1944).
|
||||
/// `SetQuestItem` and `SetQuestLogItem`, the reward hovers (`QuestFrameTemplates.xml:148`,
|
||||
/// `QuestLogFrame.xml:113`), render the row's item by id; a missing row leaves the tooltip empty.
|
||||
#[test]
|
||||
fn quest_item_hovers_render_the_rows_item_by_id() {
|
||||
let mut s = script();
|
||||
@@ -236,7 +224,7 @@ fn quest_item_hovers_render_the_rows_item_by_id() {
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
// The detail hangs on the row now, so the tooltip reads it through the selection (2247).
|
||||
// The detail hangs on the selected row, so select it first.
|
||||
s.run("SelectQuestLogEntry(1)").unwrap();
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("reward", 1)"#)
|
||||
.unwrap();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The engine spell/aura tooltip channel (decision 0274 P2, law per 0276): the verified line
|
||||
//! shapes — name|rank, ONE cost|range line, ONE casttime|cooldown line, the passive omission,
|
||||
//! the aura variant (white description + the SetPlayerBuff-only remaining line, itself gold —
|
||||
//! B62), and SetAction's
|
||||
//! pure delegation.
|
||||
//! The engine spell and aura tooltips: name|rank, one cost|range line, one casttime|cooldown line,
|
||||
//! the passive omission, the aura variant (white description, and the gold remaining line on
|
||||
//! `SetPlayerBuff` only), and the selectors that delegate to them.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
@@ -20,7 +18,7 @@ fn fireball() -> SpellTooltipView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The spellbook hover: name|rank gray, cost|range one line, casttime line, gold description.
|
||||
/// The spellbook hover: no rank column, cost|range on one line, cast time, gold description.
|
||||
#[test]
|
||||
fn spellbook_hover_renders_the_verified_shape() {
|
||||
let mut s = script();
|
||||
@@ -53,7 +51,6 @@ fn spellbook_hover_renders_the_verified_shape() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The description wears the byte-verified gold.
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let gold = quads.iter().any(|q| {
|
||||
@@ -64,13 +61,8 @@ fn spellbook_hover_renders_the_verified_shape() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`bookType` decides which book the hover reads** — the fork `SetSpell 0x532d10` makes at
|
||||
/// `0x532e1c`/`0x532e2a` (`[4*i + 0xb6f098]` for the pet, `[4*i + 0xb700f0]` otherwise).
|
||||
///
|
||||
/// The defect (1050): the binding took the argument and dropped it, so a pet-book hover indexed the
|
||||
/// PLAYER's slot list — the imp's first spell showed the player's first spell, "Attack" and its
|
||||
/// crit line, on the director's screen. Both books are populated here with a *different* spell at
|
||||
/// the same slot id, which is the only arrangement that can tell the two apart.
|
||||
/// `SetSpell`'s `bookType` picks the book (`0x532d10` forks at `0x532e1c`, `0x532e2a`: the pet's
|
||||
/// slots at `0xb6f098`, the player's at `0xb700f0`); both books hold a different spell at slot 1.
|
||||
#[test]
|
||||
fn a_pet_book_hover_reads_the_pets_book_not_the_players() {
|
||||
let mut s = script();
|
||||
@@ -126,9 +118,8 @@ fn a_pet_book_hover_reads_the_pets_book_not_the_players() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The tracking icon's hover (SetTrackingSpell): a GOLD name over the white (aura-variant)
|
||||
/// description — the shape the director's reference A/B pinned (2026-07-20), distinct from
|
||||
/// SetPlayerBuff's white name. No cost/casttime lines, no duration-remaining line.
|
||||
/// `SetTrackingSpell`, as the reference draws it: a gold name over the white aura description,
|
||||
/// with no cost, cast time or remaining line.
|
||||
#[test]
|
||||
fn tracking_hover_renders_gold_name_over_white_description() {
|
||||
let mut s = script();
|
||||
@@ -178,7 +169,7 @@ fn tracking_hover_renders_gold_name_over_white_description() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A passive spell omits the casttime|cooldown line whole — never a "Passive" text line.
|
||||
/// A passive spell omits the casttime|cooldown line whole, with no "Passive" text line.
|
||||
#[test]
|
||||
fn passive_omits_the_casttime_line() {
|
||||
let mut s = script();
|
||||
@@ -220,8 +211,8 @@ fn passive_omits_the_casttime_line() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The buff hover: the aura variant — WHITE description + the duration-remaining line (only
|
||||
/// this entry point), computed off the aura's GetTime expiry.
|
||||
/// The buff hover is the aura variant: a white description and, on this entry point only, the
|
||||
/// remaining-time line computed off the aura's `GetTime` expiry.
|
||||
#[test]
|
||||
fn player_buff_hover_is_the_aura_variant() {
|
||||
let mut s = script();
|
||||
@@ -272,7 +263,6 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The aura description is WHITE (the byte-verified aura/spell difference).
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let white = quads.iter().any(|q| {
|
||||
@@ -280,18 +270,14 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
if t.starts_with("Intellect increased") && *c == [1.0, 1.0, 1.0, 1.0])
|
||||
});
|
||||
assert!(white, "aura description is white, not gold");
|
||||
// …and the NAME is GOLD: the aura builder `0x52f880` writes it through the gold wrapper
|
||||
// `0x530380`, where the spell builder uses the plain `0x530270`. Pinned against the
|
||||
// reference's own buff hover (2026-07-25 report B53), which the white name did not match.
|
||||
// The name is gold: the aura builder `0x52f880` writes it through the gold wrapper
|
||||
// `0x530380`, where the spell builder uses the plain `0x530270`.
|
||||
let gold_name = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(gold_name, "aura name is gold, not white");
|
||||
// …and so is the DURATION-REMAINING line — the same `0xffffd200`, not the description's
|
||||
// white. On a 1.12.1 reference shot the "29 minutes remaining" glyphs are exactly
|
||||
// `(255, 210, 0)`, the same pixels as that shot's title row, while its description rows are
|
||||
// `(255, 255, 255)`.
|
||||
// So is the remaining line, the same `0xffffd200`, as in the reference's buff hover.
|
||||
let gold_remaining = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "<2 mm>" && *c == [1.0, 210.0 / 255.0, 0.0, 1.0])
|
||||
@@ -300,10 +286,8 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The buff builder `0x52f880`'s right column: the buff hover names the DISPEL CLASS where the
|
||||
/// spell hover would put a gray "Rank N" — "Magic" on Ice Armor, the half of B53 the gold name
|
||||
/// didn't cover — and it is gold, sharing the aura name's wrapper. A `rank` on the same view must
|
||||
/// NOT displace it.
|
||||
/// The buff builder's (`0x52f880`) right column is the dispel class, gold through the aura name's
|
||||
/// wrapper, where the spell hover puts a gray rank; a `rank` on the view must not displace it.
|
||||
#[test]
|
||||
fn player_buff_hover_names_the_dispel_class_in_gold() {
|
||||
let mut s = script();
|
||||
@@ -312,7 +296,7 @@ fn player_buff_hover_names_the_dispel_class_in_gold() {
|
||||
168,
|
||||
SpellTooltipView {
|
||||
name: "Ice Armor".into(),
|
||||
rank: Some("Rank 1".into()), // the spell variant's column — never the aura's
|
||||
rank: Some("Rank 1".into()), // the spell variant's column, never the aura's
|
||||
dispel_type: Some("Magic".into()),
|
||||
aura_description: "Encases the caster in a layer of ice.".into(),
|
||||
..Default::default()
|
||||
@@ -350,9 +334,8 @@ fn player_buff_hover_names_the_dispel_class_in_gold() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `0x52e610`'s equipped-item line and its reagents line — the two lines the 2026-07-25 reports
|
||||
/// found missing (B54, B56). Order: cast|cooldown → requires-item → requires-form → reagents →
|
||||
/// description, each requirement red while unmet.
|
||||
/// `0x52e610`'s line order: cast|cooldown, requires-item, requires-form, reagents, description,
|
||||
/// each requirement red while unmet.
|
||||
#[test]
|
||||
fn requirement_and_reagent_lines_render_in_law_order() {
|
||||
let mut s = script();
|
||||
@@ -401,10 +384,8 @@ fn requirement_and_reagent_lines_render_in_law_order() {
|
||||
if t == "Requires Wands" && *c == [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0])
|
||||
});
|
||||
assert!(red, "an unmet equipped-item requirement is red");
|
||||
// The reagent's inline `|cffff2020` escape reaches the region VERBATIM: the engine paints
|
||||
// the line's BASE colour white and the app's text layer resolves the escape into colour runs
|
||||
// (`benilla::ui_text::markup`, covered by its own `color_runs_survive_the_wrap`). So what this
|
||||
// layer owns is that the escape is neither stripped nor pre-flattened.
|
||||
// The reagent's `|cffff2020` escape reaches the region verbatim over a white base colour; the
|
||||
// app's text layer (`ui_text::markup`) turns it into colour runs.
|
||||
let verbatim = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Reagents: |cffff2020Light Feather|r" && *c == [1.0, 1.0, 1.0, 1.0])
|
||||
@@ -416,9 +397,9 @@ fn requirement_and_reagent_lines_render_in_law_order() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The target-frame aura hover: SetUnitBuff/SetUnitDebuff render the aura variant for the token's
|
||||
/// sign-filtered list — WITHOUT the duration-remaining line (byte-verified: only SetPlayerBuff
|
||||
/// appends it, and no other unit carries a duration on the 1.12 wire anyway).
|
||||
/// `SetUnitBuff`/`SetUnitDebuff` render the aura variant from the unit's sign-filtered list, with
|
||||
/// no remaining line: only `SetPlayerBuff` appends it, and the 1.12 wire carries no other unit's
|
||||
/// durations.
|
||||
#[test]
|
||||
fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() {
|
||||
let mut s = script();
|
||||
@@ -477,20 +458,9 @@ fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`SetPlayerBuff` reads a 1.12 cache position, not an Era ordinal** — the index space the whole
|
||||
/// `GetPlayerBuff*` family shares (`script::aura`'s header; pinned by `ref-BuffFrame.lua:105`,
|
||||
/// which passes `GetPlayerBuff`'s return straight in).
|
||||
///
|
||||
/// Every assertion here is a case the previous 1-based, sign-filtered reading got wrong, and got
|
||||
/// wrong *silently* — the plate still rendered, just for the wrong aura:
|
||||
///
|
||||
/// - position 1 is the SECOND aura, not the first again;
|
||||
/// - a debuff is reachable at all (the old sign filter defaulted to helpful, so no position ever
|
||||
/// resolved to one);
|
||||
/// - `-1` clears instead of showing the first buff. `BigWigs/Raids/Naxxramas/Loatheb.lua:260-271`
|
||||
/// feeds an unchecked `GetPlayerBuff(i, "HARMFUL")` in and breaks its scan when line 1 goes nil,
|
||||
/// so "shows something" there is an addon that never stops scanning;
|
||||
/// - a surplus filter argument is ignored (`CT_BuffMod/CT_BuffFrame.lua:151` passes one).
|
||||
/// `SetPlayerBuff` takes a 1.12 cache position, the `GetPlayerBuff` family's index space:
|
||||
/// `BuffFrame.lua:105` passes that return straight in. A miss, `-1` included, clears the plate,
|
||||
/// and a surplus filter argument is ignored.
|
||||
#[test]
|
||||
fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() {
|
||||
let mut s = script();
|
||||
@@ -509,7 +479,7 @@ fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() {
|
||||
},
|
||||
);
|
||||
}
|
||||
// The player's cache: two buffs then a debuff, ONE list, insertion-ordered (decision 0257).
|
||||
// The player's cache: two buffs then a debuff, in one insertion-ordered list.
|
||||
s.set_auras(
|
||||
"player",
|
||||
Some(vec![
|
||||
@@ -562,8 +532,8 @@ fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() {
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// SetAction delegates by payload kind: a SPELL slot renders the spell view; an empty slot shows
|
||||
/// nothing; a miss records the ask.
|
||||
/// `SetAction` delegates by payload kind: a spell slot renders the spell view, and a spell the
|
||||
/// store lacks shows no plate and records the ask.
|
||||
#[test]
|
||||
fn action_hover_delegates_by_kind() {
|
||||
let mut s = script();
|
||||
@@ -581,7 +551,7 @@ fn action_hover_delegates_by_kind() {
|
||||
25,
|
||||
Some(ActionSlot {
|
||||
kind: 0x00,
|
||||
action: 5143, // not in the store — the ask channel fires
|
||||
action: 5143, // not in the store: the ask channel fires
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
@@ -603,10 +573,8 @@ fn action_hover_delegates_by_kind() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A MACRO slot's hover is the reference's `0x52b040`: ONE white line, the macro's name (the
|
||||
/// "normal" colour `0xc0cf60` = 0xffffffff), and a slot whose
|
||||
/// macro no longer exists shows no plate at all. The director's report after 1636 landed: a macro
|
||||
/// on the bar had no tooltip — the arm was a pre-0983 `_ => Ok(())`.
|
||||
/// A macro slot's hover is the reference's `0x52b040`: one line, the macro's name in the normal
|
||||
/// colour (`0xc0cf60` holds `0xffffffff`, white); a slot whose macro is gone shows no plate.
|
||||
#[test]
|
||||
fn action_hover_on_a_macro_slot_shows_its_name_in_white() {
|
||||
let mut s = script();
|
||||
@@ -684,12 +652,8 @@ fn trainer_service(spell_id: u32, name: &str, tooltip: TrainerTooltip) -> Traine
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetTrainerService` is a SELECTOR: it renders no line of its own and routes to whichever shared
|
||||
/// builder the app-side law picked. All three arms are pinned here — the item arm, the spell arm
|
||||
/// (with `altCaster` suppressing the reagents block), and a header row, which is a no-op.
|
||||
///
|
||||
/// The index is a VISIBLE row index, so row 1 is the "Arms" header and the services start at 2 —
|
||||
/// the same interleave `SetTradeSkillItem` has, and the same way to get it wrong.
|
||||
/// `SetTrainerService` renders no line of its own: it routes to the item or spell builder the app
|
||||
/// picked (`altCaster` drops the reagents), and its index is a visible row, so row 1 is a header.
|
||||
#[test]
|
||||
fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
|
||||
let mut s = script();
|
||||
@@ -701,8 +665,8 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
|
||||
quality: 1,
|
||||
class: 2,
|
||||
subclass: 7,
|
||||
// The type cell's word is app-resolved off ItemSubClass.dbc, never composed in the
|
||||
// renderer — a view that carries none prints no type cell.
|
||||
// The app resolves the type cell's word from ItemSubClass.dbc; a view without one
|
||||
// prints no type cell.
|
||||
sub_class_display: Some("Sword".into()),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -759,7 +723,7 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Row 3: the class-trainer service -> the SPELL builder on the TAUGHT spell, reagents shown.
|
||||
// Row 3: the class-trainer service goes to the spell builder on the taught spell.
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(AB1, "ANCHOR_RIGHT")
|
||||
@@ -781,7 +745,7 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
|
||||
"the gold description is part of the shared builder's law: {texts:?}"
|
||||
);
|
||||
|
||||
// Row 4: the same view, altCaster set -> the reagents block is gone, everything else stays.
|
||||
// Row 4: the same view with altCaster set loses only the reagents block.
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(AB1, "ANCHOR_RIGHT")
|
||||
@@ -801,11 +765,8 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `SetCraftSpell` is `SetTrainerService`'s structural twin: a selector, not the two-line
|
||||
/// name/description stub it used to be. Both arms are pinned, and the assertion that matters most
|
||||
/// is the negative one — a rod recipe's hover shows the ROD's item tooltip while the same row's
|
||||
/// ICON is the spell's (Law D, decision 1107). Icon and tooltip disagreeing on one row is the
|
||||
/// verified shape, not a bug to reconcile.
|
||||
/// `SetCraftSpell` is a selector like `SetTrainerService`: a rod recipe hovers the rod's item
|
||||
/// tooltip while its row's icon is the spell's, which is the reference's shape, not a bug.
|
||||
#[test]
|
||||
fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
|
||||
let mut s = script();
|
||||
@@ -893,10 +854,8 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The duration line's **gate**: `0x532b00` skips the duration block on `untilCancelled`
|
||||
/// (`532bda: 8b 46 0c` / `532bdf: 75 2d`) — never on "has this aura a duration yet". The three
|
||||
/// cases below are the ones that separate the two questions, and the middle two are what the old
|
||||
/// `duration > 0 && left > 0` gate got wrong.
|
||||
/// `0x532b00` skips the remaining line on `untilCancelled` (`0x532bda`, `0x532bdf`), never on
|
||||
/// whether the aura has a duration yet.
|
||||
#[test]
|
||||
fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
let mut s = script();
|
||||
@@ -943,8 +902,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 1 · Permanent — and carrying a duration anyway, so only the FLAG can be doing the work.
|
||||
// Title + description, nothing else.
|
||||
// 1 · Permanent, with a duration anyway, so only the flag can suppress the line.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 1800.0,
|
||||
@@ -956,8 +914,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
"untilCancelled suppresses the line even with a live duration on the record"
|
||||
);
|
||||
|
||||
// 2 · Timed, but no duration packet has landed yet — the frame an aura appears. The old gate
|
||||
// blanked this; the reference shows the line, counting from zero.
|
||||
// 2 · Timed, before any duration packet: the reference shows the line, counting from zero.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 0.0,
|
||||
@@ -969,8 +926,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
"a timed aura's first frames still carry the line"
|
||||
);
|
||||
|
||||
// 3 · Lapsed — the reading the reference's own truncating seconds arm produces, which the old
|
||||
// gate hid entirely.
|
||||
// 3 · Lapsed: the reference's truncating seconds arm reads 0.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 30.0,
|
||||
@@ -982,8 +938,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
"a lapsed aura reads 0, it does not lose the line"
|
||||
);
|
||||
|
||||
// Control: an ordinary live aura still counts, so none of the above is the line going missing
|
||||
// for an unrelated reason.
|
||||
// Control: an ordinary live aura still counts down.
|
||||
assert_eq!(
|
||||
show(AuraState {
|
||||
duration: 1800.0,
|
||||
@@ -996,9 +951,9 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `GetRewardSpell()` / `GetQuestLogRewardSpell()` answer `texture, name, isTradeskillSpell` —
|
||||
/// three nils when the quest teaches nothing (the reference's own `(nil,nil,nil)` kind) — and
|
||||
/// `GameTooltip:SetQuestRewardSpell()` / `SetQuestLogRewardSpell()` render that spell (1944).
|
||||
/// `GetRewardSpell()` and `GetQuestLogRewardSpell()` answer `texture, name, isTradeskillSpell`,
|
||||
/// three nils when the quest teaches nothing, and `SetQuestRewardSpell()` and
|
||||
/// `SetQuestLogRewardSpell()` render that spell.
|
||||
#[test]
|
||||
fn quest_reward_spell_getters_and_hovers() {
|
||||
let mut s = script();
|
||||
@@ -1066,7 +1021,7 @@ fn quest_reward_spell_getters_and_hovers() {
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
// The detail hangs on the row now, so both readers go through the selection (2247).
|
||||
// The detail hangs on the row, so both readers go through the selection.
|
||||
s.run("SelectQuestLogEntry(1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(String, String, Option<i64>)>("return GetQuestLogRewardSpell()")
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
//! The engine unit tooltip builder (decision 0274 P3, law per 0276): the level-line composition
|
||||
//! (four `TOOLTIP_UNIT_LEVEL*` keys, the `ELITE`/`BOSS` rank table, "??", `CORPSE`, "Race Class"
|
||||
//! over `PLAYER` — all seeded here as marked stand-ins, see [`seed_level_strings`]), the flag lines
|
||||
//! (PvP white / Skinnable red / Civilian green), the world-mouseover drive (default anchor +
|
||||
//! `UPDATE_MOUSEOVER_UNIT` recolor + the fade arm on loss), and the health-bar watcher.
|
||||
//! The engine unit tooltip builder: the level line, the flag lines, the world-mouseover drive and
|
||||
//! the health-bar watcher.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in string table for the level line — the four `TOOLTIP_UNIT_LEVEL*` templates and the
|
||||
/// three word slots that fill them, **deliberately not the shipped wording**. What these tests
|
||||
/// establish is *which key* each slot combination reaches and what fills it, never what the
|
||||
/// sentence says (decision 2045, "assert the identifier, not the sentence"), and here that is not
|
||||
/// a formality: `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is word-for-word
|
||||
/// `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`, and the bare
|
||||
/// template's "Level %s" is also `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. An
|
||||
/// assertion on the English would pass on all seven.
|
||||
/// Stand-in level-line strings, deliberately not the shipped wording: the enUS templates repeat
|
||||
/// other globals word for word, so only a marked stand-in shows which key was reached.
|
||||
fn seed_level_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -46,7 +37,7 @@ fn wolf() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The creature law: gold name, subtitle, the CLASS_TYPE level line, red Skinnable.
|
||||
/// A creature: gold name, subtitle, the CLASS_TYPE level line, red Skinnable.
|
||||
#[test]
|
||||
fn creature_line_law() {
|
||||
let mut s = script();
|
||||
@@ -82,10 +73,8 @@ fn creature_line_law() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The snapshot the app pushes for a creature the client has only just seen: the DESCRIPTOR's
|
||||
/// fields are in (level, reaction, the skinnable flag) and everything the creature record carries
|
||||
/// — name, subtitle, type word, rank, civilian/leader — is still absent, because they all arrive
|
||||
/// together in the one `SMSG_CREATURE_QUERY_RESPONSE` that fills `CGUnit+0xb30`.
|
||||
/// A creature just seen: its descriptor fields are in, but the record fields (name, subtitle, type,
|
||||
/// rank, civilian) wait for the `SMSG_CREATURE_QUERY_RESPONSE` that fills `CGUnit+0xb30`.
|
||||
fn unqueried_wolf() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
@@ -101,22 +90,9 @@ fn unqueried_wolf() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A name still in flight titles the plate `UNKNOWNOBJECT` — never an empty line** (decision
|
||||
/// 2040, closing 2002's residue).
|
||||
///
|
||||
/// The builder's name read is `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), the same resolver
|
||||
/// `UnitName 0x517020` delegates to, and every one of its misses — a null `CGUnit+0xb30`
|
||||
/// (`0x609353 je 0x609324`) among them — ends at the same `FrameScript_GetText("UNKNOWNOBJECT")`
|
||||
/// tail. So the verb and the plate answer the same string for the same instant, and because the
|
||||
/// string is read out of the VM's `_G` it translates with `GlobalStrings.lua`.
|
||||
///
|
||||
/// The builder has NO counterpart to `UnitName`'s two nils: the `"player"` fast path is the
|
||||
/// *binding's* (`0x517083`, before any resolve), so a player whose name has not arrived titles
|
||||
/// `UNKNOWNOBJECT` too; and a token resolving to nothing never reaches a builder at all — the
|
||||
/// entry gate hands back no object, `SetUnit` answers nil and no plate is drawn.
|
||||
///
|
||||
/// Failure looks like the empty first line the residue named: `TextLeft1` reads `""`, the plate
|
||||
/// measures to a blank row, and the level line reads as the tooltip's title.
|
||||
/// `CGUnit_C::GetUnitName 0x609210` (`0x52a187`) reads the name, as for `UnitName 0x517020`, and
|
||||
/// every miss, a null `CGUnit+0xb30` among them (`0x609353 je 0x609324`), ends at
|
||||
/// `FrameScript_GetText("UNKNOWNOBJECT")`. The `"player"` fast path is the binding's (`0x517083`).
|
||||
#[test]
|
||||
fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
let mut s = script();
|
||||
@@ -145,9 +121,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The query answers: the very next render replaces the placeholder with the real name and
|
||||
// fills the record's lines. (Live, the hover feed re-drives this without the mouse moving —
|
||||
// its rebuild key is the whole line-affecting snapshot, `ui_tooltip::lines_view`.)
|
||||
// The query answers: the next render replaces the placeholder and fills the record's lines.
|
||||
let mut answered = unqueried_wolf();
|
||||
answered.name = Some("Timber Wolf".into());
|
||||
answered.subtitle = Some("Alpha".into());
|
||||
@@ -165,8 +139,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// An EMPTY (or absent) global is the same miss as no global — `0x609324`'s own check, which
|
||||
// falls to the binary's literal `0x860fa4`.
|
||||
// An empty or absent global falls to the binary's own literal `0x860fa4` (`0x609324`).
|
||||
s.set_unit("target", Some(unqueried_wolf()));
|
||||
s.run(
|
||||
r#"
|
||||
@@ -177,8 +150,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The `"player"` fast path is the BINDING's, not the builder's: a player whose name-cache row
|
||||
// has not answered titles the same placeholder, where `UnitName("player")` would push nil.
|
||||
// A player whose name has not arrived titles the placeholder; `UnitName("player")` pushes nil.
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
@@ -203,18 +175,15 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The GUID-0 counterpart: a recognised token naming nothing resolves to no object, so no
|
||||
// builder runs — nil, and no plate. (There is no third answer; the builder never sees it.)
|
||||
// A recognised token naming nothing resolves to no object, so no builder runs.
|
||||
s.run(r#"assert(TT:SetUnit("party4") == nil, "an absent unit draws no plate at all")"#)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The faction-name line sits between the level line and "PvP" (the builder-tail block of
|
||||
/// `0x529fe0` — the director's Marshal McBride reference: Level, Stormwind, PvP); the
|
||||
/// CIVILIAN line is the dishonorable-kill warning, whole gate (`0x612550`): PvP bit + civilian
|
||||
/// flag + HOSTILE + GREY/trivial — a friendly (or non-grey) civilian never shows it; LEADER
|
||||
/// (white) needs only the PvP bit + the flag (`0x6125c0`).
|
||||
/// The faction line sits between the level line and "PvP" (`0x529fe0`). Civilian needs the PvP
|
||||
/// bit, the flag, hostility and a grey level (`0x612550`); Leader only the PvP bit and the flag
|
||||
/// (`0x6125c0`).
|
||||
#[test]
|
||||
fn faction_line_and_civilian_gate() {
|
||||
let mut s = script();
|
||||
@@ -224,7 +193,7 @@ fn faction_line_and_civilian_gate() {
|
||||
level: 30,
|
||||
..Default::default()
|
||||
});
|
||||
// The reference shot's shape: a FRIENDLY civilian guard — faction line, PvP, NO Civilian.
|
||||
// A friendly civilian guard: faction line and PvP, no Civilian.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
@@ -252,7 +221,7 @@ fn faction_line_and_civilian_gate() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The warning case: HOSTILE + grey (level 20 vs player 30 → gap 10 > band 8) + PvP-flagged.
|
||||
// Hostile, PvP-flagged and grey (a gap of 10 is past the band of 8): the warning shows.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
@@ -278,7 +247,7 @@ fn faction_line_and_civilian_gate() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Same unit but NOT grey (level 25, gap 5 ≤ band 8): the warning drops.
|
||||
// Not grey (a gap of 5 is inside the band of 8): no warning.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
@@ -380,7 +349,7 @@ fn level_line_variants() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The "??" gate, byte-pinned: HOSTILE (reaction ≤ 2) + 10 levels up reads "??"…
|
||||
// A hostile unit (reaction 2 or less) 10 levels up reads "??".
|
||||
let ten_up = |reaction: u8, is_player: bool| UnitState {
|
||||
exists: true,
|
||||
name: Some("Elder".into()),
|
||||
@@ -400,7 +369,7 @@ fn level_line_variants() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// …but UNFRIENDLY (reaction 3) does not — the internal bound is hated/hostile only…
|
||||
// An unfriendly one (reaction 3) does not.
|
||||
s.set_unit("target", Some(ten_up(3, false)));
|
||||
s.run(
|
||||
r#"
|
||||
@@ -409,7 +378,7 @@ fn level_line_variants() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// …and PLAYERS never read "??" regardless of reaction/delta.
|
||||
// Players never read "??".
|
||||
s.set_unit("target", Some(ten_up(2, true)));
|
||||
s.run(
|
||||
r#"
|
||||
@@ -421,9 +390,7 @@ fn level_line_variants() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The world-mouseover drive: `world_tooltip_unit` fires the default-anchor script, renders,
|
||||
/// fires `UPDATE_MOUSEOVER_UNIT` (the Lua recolor), and the health bar tracks later pushes;
|
||||
/// hover loss arms the fade (alpha ramps, then hides).
|
||||
/// `world_tooltip_unit` fires the default anchor, renders, then fires `UPDATE_MOUSEOVER_UNIT`.
|
||||
#[test]
|
||||
fn world_hover_drive_and_health_watcher() {
|
||||
let mut s = script();
|
||||
@@ -466,8 +433,7 @@ fn world_hover_drive_and_health_watcher() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The handler's seating resolved: bottom-right of the 800×600 screen, −13 in, 70 up.
|
||||
// (Answer the line measures first — the auto-size needs them before the plate has a rect.)
|
||||
// The auto-size needs the line measures before the plate has a rect.
|
||||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
.iter()
|
||||
@@ -480,7 +446,7 @@ fn world_hover_drive_and_health_watcher() {
|
||||
"plate at the default corner, got " .. tostring(GameTooltip:GetRight()) .. "," .. tostring(GameTooltip:GetBottom()))"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A health push for the LIVE token re-drives the bar without a rebuild.
|
||||
// A health push for the live token re-drives the bar without a rebuild.
|
||||
let mut hurt = wolf();
|
||||
hurt.health = 12;
|
||||
s.set_unit("mouseover", Some(hurt));
|
||||
@@ -488,7 +454,6 @@ fn world_hover_drive_and_health_watcher() {
|
||||
r#"assert(GameTooltipStatusBar:GetValue() == 12, "the health watcher tracked the push")"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Hover loss: the fade arms; past the ramp the tooltip hides.
|
||||
s.world_tooltip_fade();
|
||||
s.tick(0.6);
|
||||
s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#)
|
||||
@@ -496,9 +461,8 @@ fn world_hover_drive_and_health_watcher() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The minimap BLIP tooltip (`minimap_tooltip`): refuses without a Minimap widget; with one, one
|
||||
/// white line seated against the widget (the INTERIM ANCHOR_LEFT law), world-owned so the shared
|
||||
/// fade arm hides it on hover loss.
|
||||
/// The minimap blip tooltip (`minimap_tooltip`): refused without `UIParent`, otherwise one line
|
||||
/// seated above the cursor, world-owned so the shared fade hides it on hover loss.
|
||||
#[test]
|
||||
fn minimap_blip_tooltip_shows_and_fades() {
|
||||
let mut s = script();
|
||||
@@ -524,8 +488,7 @@ fn minimap_blip_tooltip_shows_and_fades() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Seated 5 px from the 800-wide screen's right edge: the clamp (the client's G bit4)
|
||||
// slides the plate back inside instead of letting it clip (director-caught).
|
||||
// Seated 5 px from the right edge, the clamp (the client's G bit4) slides the plate inside.
|
||||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
.iter()
|
||||
@@ -537,11 +500,10 @@ fn minimap_blip_tooltip_shows_and_fades() {
|
||||
r#"assert(GameTooltip:GetRight() <= 800, "clamped inside the screen, got " .. tostring(GameTooltip:GetRight()))"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Follow: a move re-seats without rebuilding the line.
|
||||
s.world_tooltip_move(400.0, 300.0);
|
||||
s.run(r#"assert(GameTooltipTextLeft1:GetText() == "Stormwind", "content survives a move")"#)
|
||||
.unwrap();
|
||||
// Hover loss rides the shared world fade: armed, then hidden past the ramp.
|
||||
// Hover loss rides the shared world fade.
|
||||
s.world_tooltip_fade();
|
||||
s.tick(0.6);
|
||||
s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
//! The `toplevel` flag and the raise law — `SetToplevel`/`IsToplevel`, `Raise`/`Lower`, and the
|
||||
//! raise itself with its real trigger (Show) and its real gate (occlusion). The mechanism, the byte
|
||||
//! addresses and the clause-by-clause mapping onto our order model are in `script::object::toplevel`.
|
||||
//!
|
||||
//! Every test drives the PRODUCTION path — the Lua bindings, the loader, and the real
|
||||
//! `mouse_button`/`mouse_move` entry points — and asserts on the two things that are actually
|
||||
//! observable: `GetFrameLevel()` (the arithmetic) and the extracted painter order (the pixels).
|
||||
//! The `toplevel` flag and the raise: `SetToplevel`/`IsToplevel`, `Raise`/`Lower`, and the raise
|
||||
//! on show, press and drag, gated on occlusion. Tests assert `GetFrameLevel()` and the paint order.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{QuadContent, UiScript};
|
||||
|
||||
/// The order textures actually paint in, by their texture path — the render list `extract` returns
|
||||
/// IS the draw order (`order::traversal`'s ZKey sort).
|
||||
/// Texture paths in paint order: the list `extract` returns is the draw order.
|
||||
fn painted(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
@@ -27,14 +21,8 @@ fn level(s: &mut UiScript, frame: &str) -> i64 {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Two overlapping MEDIUM frames: `Board` — already on screen and sitting at frame level **5** —
|
||||
/// and `Dialog`, born at level 0 and hidden.
|
||||
///
|
||||
/// The level split is the point. A frame's link stamp already lifts it within its bucket when it is
|
||||
/// shown (`resequence_to_tail`, decision 0557), so a same-level sibling would prove nothing about
|
||||
/// the raise; **level outranks the link stamp** in the draw key, so nothing but a real level bump
|
||||
/// can get `Dialog` over `Board`. This is the shape of the complaint the law answers: a dialog
|
||||
/// opening behind a window that happens to sit higher in its stratum.
|
||||
/// Two overlapping MEDIUM frames: `Board`, shown at level 5, and `Dialog`, hidden at level 0. The
|
||||
/// level outranks the link stamp in the draw key, so only a real level bump puts `Dialog` on top.
|
||||
fn board_and_dialog() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
@@ -58,11 +46,9 @@ fn board_and_dialog() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
// ── The flag ────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The flag ──
|
||||
|
||||
/// `SetToplevel`/`IsToplevel` round-trip, including the reference's **default-true** optional
|
||||
/// argument (`0x775440` marshals an optional boolean defaulting to true, unlike `SetMovable`).
|
||||
/// Nothing is born toplevel.
|
||||
/// `SetToplevel`'s optional argument defaults to true (`0x775440`), unlike `SetMovable`'s.
|
||||
#[test]
|
||||
fn the_flag_round_trips_and_defaults_off() {
|
||||
let s = script();
|
||||
@@ -75,27 +61,19 @@ fn the_flag_round_trips_and_defaults_off() {
|
||||
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
s.run("F:SetToplevel(false)").unwrap();
|
||||
assert!(!s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
// The omitted argument is TRUE, not false — the reference's `SetToplevel()` turns the bit ON.
|
||||
s.run("F:SetToplevel()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return F:IsToplevel()").unwrap(),
|
||||
"SetToplevel() with no argument sets the bit"
|
||||
);
|
||||
// Lua truthiness, like every other flag setter here: the corpus writes SetToplevel(1).
|
||||
// Lua truthiness: addons write `SetToplevel(1)`.
|
||||
s.run("F:SetToplevel(nil); F:SetToplevel(1)").unwrap();
|
||||
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `toplevel="true"` in XML lands on the real method — it was a warn-once gap in the loader until
|
||||
/// this law existed, which left every window authored toplevel (thirteen of our own frames, 82
|
||||
/// corpus addons) unable to come to the front while `SetToplevel` did not exist at all.
|
||||
///
|
||||
/// `enableKeyboard` used to be warned here on the same principle — implementing one clause is not
|
||||
/// a licence to go quiet about the other. It now lands on a real `EnableKeyboard`, so the warning
|
||||
/// is gone and this asserts the flag instead. Key *delivery* reads the same flag
|
||||
/// (`script::keyboard`'s walk, 1319); the flag and a handler stay separable in the reference too
|
||||
/// (`0x76af00`), which is why this asserts the flag alone.
|
||||
/// The XML `toplevel` and `enableKeyboard` attributes set the flags their methods read; in the
|
||||
/// reference the keyboard flag is separate from any handler (`0x76af00`).
|
||||
#[test]
|
||||
fn the_xml_toplevel_and_enable_keyboard_attributes_both_reach_their_methods() {
|
||||
let s = script();
|
||||
@@ -126,8 +104,7 @@ fn the_xml_toplevel_and_enable_keyboard_attributes_both_reach_their_methods() {
|
||||
s.eval::<bool>("return XmlTop:IsKeyboardEnabled()").unwrap(),
|
||||
"the XML attribute must reach the flag"
|
||||
);
|
||||
// Default false, and the setter round-trips both ways — a frame that never asked is not in the
|
||||
// bucket (`0x76af00` is reached from the attribute or an explicit call, never a ctor).
|
||||
// Off by default: `0x76af00` runs from the attribute or a call, never a constructor.
|
||||
s.run("Plain = CreateFrame('Frame', 'PlainKbd')").unwrap();
|
||||
assert!(!s.eval::<bool>("return Plain:IsKeyboardEnabled()").unwrap());
|
||||
s.run("Plain:EnableKeyboard(true)").unwrap();
|
||||
@@ -136,10 +113,9 @@ fn the_xml_toplevel_and_enable_keyboard_attributes_both_reach_their_methods() {
|
||||
assert!(!s.eval::<bool>("return Plain:IsKeyboardEnabled()").unwrap());
|
||||
}
|
||||
|
||||
// ── The raise on Show ───────────────────────────────────────────────────────────────────────────
|
||||
// ── The raise on Show ──
|
||||
|
||||
/// The control, and the behaviour before this landed: a **non-toplevel** frame is shown and moves
|
||||
/// nowhere. `Dialog` paints under `Board` because its level is lower, exactly as the draw key says.
|
||||
/// The control: a frame that is not toplevel keeps its level when shown, and paints under `Board`.
|
||||
#[test]
|
||||
fn a_non_toplevel_frame_does_not_move_when_shown() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -153,14 +129,8 @@ fn a_non_toplevel_frame_does_not_move_when_shown() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The headline: the same frame, marked `toplevel`, comes to the front on **Show** — the trigger
|
||||
/// the reference raises on (`0x76ae10` @`0x76aee0`), not on a click.
|
||||
///
|
||||
/// The arithmetic is pinned here because it is the whole mapping onto our order model. Visible
|
||||
/// MEDIUM levels are `{0 (Dialog), 5 (Board)}`; level compaction (`0x764eb0`) renumbers those
|
||||
/// occupied levels contiguously into `[0, 2)` — Board 5 → 1 — and the raise then writes
|
||||
/// `level := bucket->count` = **2**. Not a live max-scan (`5 + 1`), not a re-stamp: one above the
|
||||
/// top *occupied* level, counted after the compaction.
|
||||
/// A toplevel frame raises on show (`0x76ae10` at `0x76aee0`): compaction (`0x764eb0`) renumbers
|
||||
/// the occupied levels `{0, 5}` to `[0, 2)`, then the raise writes `level := bucket->count`, 2.
|
||||
#[test]
|
||||
fn a_raise_is_top_occupied_level_plus_one_after_compaction() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -184,15 +154,9 @@ fn a_raise_is_top_occupied_level_plus_one_after_compaction() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The trigger is the **`effectiveVisible` false→true transition**, not the `Show()` call: a frame
|
||||
/// whose own `shown` bit is already set raises when an ancestor's Show finally makes it visible, and
|
||||
/// a `Show()` that changes no effective visibility raises nothing.
|
||||
///
|
||||
/// The setup order is load-bearing and is worth reading as the law's own proof. `Holder` is hidden
|
||||
/// *before* `Dialog:Show()`, so that Show is not a transition — leave it visible and `Dialog` raises
|
||||
/// there instead, ends up above everything, and the transition under test then correctly declines
|
||||
/// because nothing is left to raise over. (That mis-ordering is what the first draft of this test
|
||||
/// did; the gate was right and the fixture was wrong.)
|
||||
/// The trigger is the effective-visibility transition, not the `Show` call. `Holder` is hidden
|
||||
/// before `Dialog:Show()` so that call is no transition; otherwise `Dialog` raises there and has
|
||||
/// nothing left to raise over.
|
||||
#[test]
|
||||
fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -217,8 +181,8 @@ fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() {
|
||||
assert_eq!(level(&mut s, "Board"), 5, "and compacts nothing");
|
||||
|
||||
s.run("Holder:Show()").unwrap();
|
||||
// Occupied visible MEDIUM levels at the raise: Holder 0, Dialog 1, Board 5 → compaction
|
||||
// renumbers to {0, 1, 2} (Board → 2), count 3, and the raise writes Dialog := 3.
|
||||
// Levels at the raise are Holder 0, Dialog 1, Board 5; compaction makes Board 2, count 3, and
|
||||
// the raise writes Dialog := 3.
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
3,
|
||||
@@ -227,11 +191,9 @@ fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() {
|
||||
assert_eq!(level(&mut s, "Board"), 2, "the compaction ran with it");
|
||||
}
|
||||
|
||||
// ── The gate ────────────────────────────────────────────────────────────────────────────────────
|
||||
// ── The gate ──
|
||||
|
||||
/// **Occlusion-gated**: a toplevel frame that overlaps nothing changes nothing at all. The gate sits
|
||||
/// *before* the compaction in `0x7650f0`, so a declined raise leaves the whole stratum's levels
|
||||
/// untouched too — `Board` keeps its authored 5.
|
||||
/// The occlusion gate runs before the compaction in `0x7650f0`, so a declined raise moves no level.
|
||||
#[test]
|
||||
fn a_raise_on_a_frame_that_overlaps_nothing_is_a_total_no_op() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -253,9 +215,7 @@ fn a_raise_on_a_frame_that_overlaps_nothing_is_a_total_no_op() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The scan runs from the frame's **own level upward** (`for lvl = T->+0xc4; lvl < bucket->count`).
|
||||
/// A toplevel frame whose only overlap is with something *below* it is already in front of it, so
|
||||
/// there is nothing to raise over.
|
||||
/// The occlusion scan starts at the frame's own level (`for lvl = T->+0xc4; lvl < bucket->count`).
|
||||
#[test]
|
||||
fn the_scan_ignores_frames_below_the_raised_frames_own_level() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -276,9 +236,7 @@ fn the_scan_ignores_frames_below_the_raised_frames_own_level() {
|
||||
assert_eq!(level(&mut s, "Board"), 3);
|
||||
}
|
||||
|
||||
/// A window overlaps its own children by construction, so the scan excludes the raised frame's
|
||||
/// subtree (`0x767010`). Without that clause every toplevel frame with content in it
|
||||
/// would raise on every trigger.
|
||||
/// The scan excludes the raised frame's own subtree (`0x767010`).
|
||||
#[test]
|
||||
fn the_scan_excludes_the_raised_frames_own_subtree() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -299,10 +257,7 @@ fn the_scan_excludes_the_raised_frames_own_subtree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A raise **never changes the stratum** — `0x7650f0` never writes `+0xc0`. A LOW toplevel frame
|
||||
/// raises over its LOW neighbours and stays under every MEDIUM frame, however many times it is
|
||||
/// raised. (This is the mechanism that could not have fixed the party-frame-through-loot-window
|
||||
/// bug — refuted.)
|
||||
/// `0x7650f0` never writes the stratum (`+0xc0`), so a LOW toplevel frame raises within LOW only.
|
||||
#[test]
|
||||
fn a_raise_can_never_lift_a_frame_out_of_its_stratum() {
|
||||
let mut s = script();
|
||||
@@ -354,16 +309,11 @@ fn a_raise_can_never_lift_a_frame_out_of_its_stratum() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Propagation and compaction ──────────────────────────────────────────────────────────────────
|
||||
// ── Propagation and compaction ──
|
||||
|
||||
/// `propagate = 1` shifts **same-strata** children by the same delta (`0x76a4f0` @`0x76a58a`), so
|
||||
/// the raised subtree keeps its internal order; cross-strata children are untouched
|
||||
/// (`0x76a582: cmp …; jne`).
|
||||
///
|
||||
/// The arithmetic in full. Visible MEDIUM levels before the raise are `{0 Dialog, 1 Kid, 2 Grandkid,
|
||||
/// 5 Board}`; compaction maps them to `{0, 1, 2, 3}` (Board 5 → 3) and reports `count = 4`. The
|
||||
/// raise writes `Dialog := 4`, i.e. `delta = +4`, and pushes that same +4 into Kid (1 → 5) and
|
||||
/// Grandkid (2 → 6) — gaps and order preserved. `Cross`, moved to the DIALOG stratum, keeps its 1.
|
||||
/// The raise's `propagate = 1` shifts same-strata children by its delta (`0x76a4f0` at
|
||||
/// `0x76a58a`) and skips the others (`0x76a582`). Levels `{0, 1, 2, 5}` compact to `{0, 1, 2, 3}`,
|
||||
/// so Dialog goes to 4 and Kid and Grandkid move by the same +4.
|
||||
#[test]
|
||||
fn the_raised_subtree_shifts_by_one_delta_and_keeps_its_internal_order() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -394,12 +344,8 @@ fn the_raised_subtree_shifts_by_one_delta_and_keeps_its_internal_order() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compaction is what keeps `level := top + 1` from ratcheting. Two overlapping toplevel windows
|
||||
/// traded back and forth twenty times settle inside a two-level band instead of climbing one step
|
||||
/// per show — and the frame shown last is on top every single time.
|
||||
///
|
||||
/// Without level compaction (`0x764eb0`) this test ends at level 20, and a long session ends at
|
||||
/// `u16::MAX`.
|
||||
/// Compaction (`0x764eb0`) keeps `level := top + 1` from climbing: twenty alternating raises stay
|
||||
/// within a two-level band.
|
||||
#[test]
|
||||
fn compaction_bounds_the_raise_across_repeated_shows() {
|
||||
let mut s = script();
|
||||
@@ -436,11 +382,10 @@ fn compaction_bounds_the_raise_across_repeated_shows() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── The Lua verbs ───────────────────────────────────────────────────────────────────────────────
|
||||
// ── The Lua verbs ──
|
||||
|
||||
/// `Frame:Raise()` — the explicit script call (`0x775a50` → `0x76a5b0` → `0x7650f0(force = 1)`).
|
||||
/// Called on a **non-toplevel** frame it acts on the nearest toplevel ancestor, and on nothing at
|
||||
/// all when there is none: the gate lives in the worker, so no call site has to check.
|
||||
/// `Raise` (`0x775a50`, then `0x76a5b0`, then `0x7650f0` with force 1) acts on the nearest toplevel
|
||||
/// self or ancestor, and on nothing when there is none.
|
||||
#[test]
|
||||
fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -465,7 +410,7 @@ fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() {
|
||||
"raising a child raised the toplevel window it lives in"
|
||||
);
|
||||
|
||||
// A frame with no toplevel in its chain: a total no-op, and not an error.
|
||||
// No toplevel in the chain: a no-op, not an error.
|
||||
let board_before = level(&mut s, "Board");
|
||||
let loose_before = level(&mut s, "Loose");
|
||||
s.run("Loose:Raise()").unwrap();
|
||||
@@ -474,9 +419,7 @@ fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `Frame:Lower()` is a **verified no-op stub** in build 5875 (`0x7652a0` = `xor eax,eax; ret 4`,
|
||||
/// its frame argument never read). It exists so a caller gets the reference's silence rather than a
|
||||
/// nil-call error — and it must not become "the opposite of Raise".
|
||||
/// The reference's `Lower` does nothing (`0x7652a0`: `xor eax,eax; ret 4`), and neither does ours.
|
||||
#[test]
|
||||
fn lua_lower_exists_and_does_nothing() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -487,9 +430,7 @@ fn lua_lower_exists_and_does_nothing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The drag-start trigger (`0x7652b0` @`0x7652d7`): grabbing a movable toplevel window brings it
|
||||
/// forward before the drag begins. This was the deferral `object::movable`'s doc carried ("benilla
|
||||
/// has no raise law yet") and it is closed.
|
||||
/// `StartMoving` raises a toplevel window before the drag begins (`0x7652b0` at `0x7652d7`).
|
||||
#[test]
|
||||
fn starting_a_move_raises_the_dragged_window() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -516,7 +457,7 @@ fn starting_a_move_raises_the_dragged_window() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── The mouse-DOWN trigger ──────────────────────────────────────────────────────────────────────
|
||||
// ── The mouse-down trigger ──
|
||||
|
||||
/// The centre of `name`'s resolved rect, in the coordinate space [`UiScript::mouse_button`] takes.
|
||||
fn centre(s: &UiScript, name: &str) -> (f32, f32) {
|
||||
@@ -529,11 +470,7 @@ fn centre(s: &UiScript, name: &str) -> (f32, f32) {
|
||||
(x as f32, y as f32)
|
||||
}
|
||||
|
||||
/// **A press brings the window forward** — the trigger at `0x766392` in the mouse-down handler
|
||||
/// `0x7662c0`, the last of the six census sites to be wired (`0x7662c0`).
|
||||
///
|
||||
/// Without it `toplevel` gives only half the behaviour a player reads as "windows": a window you
|
||||
/// *open* comes to the front, and a window you *click* stays buried under whatever is on top of it.
|
||||
/// A press raises the window: the trigger at `0x766392` in the mouse-down handler `0x7662c0`.
|
||||
#[test]
|
||||
fn pressing_a_toplevel_window_brings_it_to_the_front() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -550,7 +487,7 @@ fn pressing_a_toplevel_window_brings_it_to_the_front() {
|
||||
s.resolve();
|
||||
assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
|
||||
|
||||
// A point inside Dialog and clear of Board, so the hit is unambiguous.
|
||||
// Board overlaps Dialog's centre but takes no mouse, so the hit is Dialog.
|
||||
let (x, y) = centre(&s, "Dialog");
|
||||
assert_eq!(s.hit_test_name(x, y).as_deref(), Some("Dialog"));
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
@@ -562,10 +499,7 @@ fn pressing_a_toplevel_window_brings_it_to_the_front() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The trigger is **UNGUARDED** — there is no toplevel test at the call site. The worker resolves
|
||||
/// the nearest toplevel *self-or-ancestor*, so pressing a plain child raises the window that owns
|
||||
/// it. This is the clause that makes clicking a window's button, tab or background art work at all;
|
||||
/// with a guard out here, only a press landing on the window frame itself would raise.
|
||||
/// The press trigger has no toplevel test; the worker raises the nearest toplevel self or ancestor.
|
||||
#[test]
|
||||
fn pressing_a_child_of_a_toplevel_window_raises_the_window() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -596,9 +530,7 @@ fn pressing_a_child_of_a_toplevel_window_raises_the_window() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The RELEASE never raises.** The sibling category-`0xe` handler `0x766420` has no `Raise` call
|
||||
/// anywhere in it; only the down edge does. A raise on the release would be invisible in the
|
||||
/// ordinary case and wrong in the one that matters — a press that started a drag elsewhere.
|
||||
/// The mouse-up handler (`0x766420`, category `0xe`) has no raise; only the down edge raises.
|
||||
#[test]
|
||||
fn releasing_over_a_toplevel_window_does_not_raise_it() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -615,7 +547,7 @@ fn releasing_over_a_toplevel_window_does_not_raise_it() {
|
||||
s.resolve();
|
||||
let (x, y) = centre(&s, "Dialog");
|
||||
let before = level(&mut s, "Dialog");
|
||||
// A release with no press before it: the down edge never ran, so nothing may move.
|
||||
// A release with no press before it.
|
||||
s.mouse_button(x, y, "LeftButton", false);
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
@@ -625,11 +557,8 @@ fn releasing_over_a_toplevel_window_does_not_raise_it() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Capture else hover.** The handler resolves `root+0x80` (the existing capture) *before* falling
|
||||
/// back to `root+0x7c` (the frame under the cursor), so a second button pressed while the first is
|
||||
/// still held raises the frame being HELD, not whatever the pointer has wandered onto. The capture
|
||||
/// clears only when the last button comes up (`0x7664bb`, gated on the post-event mask), so the
|
||||
/// next press after that resolves to the hover again.
|
||||
/// The press raises the capture (`root+0x80`) before the hover (`root+0x7c`), so a second button
|
||||
/// raises the held frame; the capture clears when the last button comes up (`0x7664bb`).
|
||||
#[test]
|
||||
fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
|
||||
let mut s = board_and_dialog();
|
||||
@@ -659,14 +588,13 @@ fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Hold the left button on Dialog…
|
||||
// Hold the left button on Dialog.
|
||||
let (dx, dy) = centre(&s, "Dialog");
|
||||
s.mouse_button(dx, dy, "LeftButton", true);
|
||||
let dialog_held = level(&mut s, "Dialog");
|
||||
let other_before = level(&mut s, "Other");
|
||||
|
||||
// …then press the right button away over Other. The capture is still Dialog's, so Other is not
|
||||
// the raise target even though it is what the cursor is on.
|
||||
// Then press the right button over Other: the capture is still Dialog's.
|
||||
let (ox, oy) = centre(&s, "Other");
|
||||
assert_eq!(s.hit_test_name(ox, oy).as_deref(), Some("Other"));
|
||||
s.mouse_button(ox, oy, "RightButton", true);
|
||||
@@ -677,7 +605,7 @@ fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
|
||||
);
|
||||
assert_eq!(level(&mut s, "Dialog"), dialog_held);
|
||||
|
||||
// Both buttons up — only now does the capture clear.
|
||||
// Both buttons up: only now does the capture clear.
|
||||
s.mouse_button(ox, oy, "LeftButton", false);
|
||||
s.mouse_button(ox, oy, "RightButton", false);
|
||||
let other_now = level(&mut s, "Other");
|
||||
@@ -690,30 +618,9 @@ fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A window raised while part of it is hidden must keep its own children level with each other**
|
||||
/// — the compaction may not split a sibling pair (director's report 2026-09-08, decision 2104).
|
||||
///
|
||||
/// This is Gatherer 1.0.0's Report window, built the way its XML builds it and reduced to the four
|
||||
/// frames that matter:
|
||||
///
|
||||
/// - `Win` — `toplevel`, `frameStrata="DIALOG"`, born hidden, at level 1 (what `SetParent` gives a
|
||||
/// child of a level-0 `UIParent`).
|
||||
/// - `Close` — a Button declared inside `Win`'s `<Frames>`, so level 2, and visible with `Win`.
|
||||
/// - `Body` — a `<Frame parent="Win" hidden="true" enableMouse="true">` covering the whole window
|
||||
/// (`GathererInfo_ReportFrame`, 640×370 centred on a 640×390 dialog), also level 2, but HIDDEN
|
||||
/// when `Win` is shown. The addon shows it one line later.
|
||||
/// - `Other` — the already-open Options window it overlaps, which is what arms the occlusion gate.
|
||||
///
|
||||
/// The sequence is the addon's: `Win:Show()` (whose raise runs while `Body` is still hidden), then
|
||||
/// `Body:Show()`. `Close` and `Body` are siblings under one parent, so their levels are equal by
|
||||
/// construction and **must stay equal** — the reference's hit sweep then gives the tie to the
|
||||
/// earlier-linked frame, which is `Close`, and the Close button works.
|
||||
///
|
||||
/// What went wrong: the compaction renumbered `Close` (visible, in the bucket) and not `Body`
|
||||
/// (hidden, in no bucket), then the raise's propagate pushed the same delta into both — so the one
|
||||
/// level the compaction squeezed out of `Close` and not out of `Body` became a permanent inversion.
|
||||
/// `Body` is mouse-enabled and covers the window, so it swallowed every click and hover the Close
|
||||
/// button should have had: the button showed no highlight and could not be pressed.
|
||||
/// Gatherer's Report window: `Win` raises while its full-cover `Body` is still hidden, then `Body`
|
||||
/// shows. Siblings `Close` and `Body` must stay level through the compaction, so the reference's
|
||||
/// hit sweep gives the tie to the earlier-linked `Close`.
|
||||
#[test]
|
||||
fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
|
||||
let mut s = script();
|
||||
@@ -757,8 +664,7 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// The addon's own two lines: show the window (the raise fires here, with `Body` still hidden),
|
||||
// then show the sub-frame for the selected tab.
|
||||
// The addon's two lines: the raise fires on the first, with `Body` still hidden.
|
||||
s.run("Win:Show()").unwrap();
|
||||
s.run("Body:Show()").unwrap();
|
||||
s.resolve();
|
||||
@@ -770,9 +676,7 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
|
||||
level(&mut s, "Other")
|
||||
);
|
||||
|
||||
// THE SYMPTOM FIRST, through the engine's own pointer path: the Close button takes its own
|
||||
// click and its own hover. Asserted before the arithmetic below so a regression reports what
|
||||
// the director would see, not the number behind it.
|
||||
// The Close button takes its own click and hover, asserted before the levels behind them.
|
||||
let (cx, cy) = centre(&s, "Close");
|
||||
assert_eq!(
|
||||
s.hit_test_name(cx, cy).as_deref(),
|
||||
@@ -792,7 +696,6 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
|
||||
"Close"
|
||||
);
|
||||
|
||||
// …and the number behind it.
|
||||
assert_eq!(
|
||||
level(&mut s, "Close"),
|
||||
level(&mut s, "Body"),
|
||||
@@ -802,11 +705,8 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **A script level change carries no children** (decision 2189): the Lua binding `0x774560`
|
||||
/// calls `set_frame_level 0x76a4f0` with `propagate=0` — only the raise shifts a subtree. Stock
|
||||
/// FrameXML is written against it (`BonusActionButtonTemplate` raises the button and then its
|
||||
/// cooldown by hand), and carrying the children put the bonus bar's sweep over an addon's
|
||||
/// cooldown count.
|
||||
/// `SetFrameLevel` (`0x774560`) calls `0x76a4f0` with `propagate=0`, so the children stay put;
|
||||
/// stock `BonusActionBarFrame.xml:13-15` raises a button and then its cooldown by hand.
|
||||
#[test]
|
||||
fn a_script_level_change_leaves_the_children_where_they_were() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Show/Hide fire OnShow/OnHide exactly on effective transitions; OnUpdate ticks gate on visibility.
|
||||
//! Show and Hide fire OnShow/OnHide only on effective transitions; OnUpdate runs only when visible.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
//! **The widget-method surface gate** — the sibling of [`super::reference_surface`], for the API
|
||||
//! an addon reaches through a *widget* rather than through `_G` (decision 2142).
|
||||
//!
|
||||
//! `our_globals_stay_inside_the_1_12_surface` has guarded the global namespace since 1189, and the
|
||||
//! shape gate (1842/1843/2118) guards the arity and return kinds of the methods we *have*. Between
|
||||
//! them sat the question neither asks: **is the set of method NAMES on each widget class the set
|
||||
//! 1.12 registers?** A method the reference has and we do not is invisible to the shape gate,
|
||||
//! which skips what does not exist; a method we have and the reference does not is invisible to
|
||||
//! everything, and is the superset `api-coverage.sh`'s header calls "not free" — an addon that
|
||||
//! feature-detects (`if region.GetBlendMode then`) takes a path we cannot honour, and the failure
|
||||
//! surfaces far from the cause.
|
||||
//!
|
||||
//! A corpus census over 110 top-20 and 219 vanilla addons found both directions live at once
|
||||
//! (2142): `ShaguTweaks` probes `GetBlendMode`, which we were missing, and silently recolours
|
||||
//! textures the reference leaves alone; `pfUI` and `_Nameplates` call `GameTooltip:GetAnchorType`
|
||||
//! bare and raise. The demand is in the corpus, which is not in this repo — so this gate does not
|
||||
//! measure demand. It measures the SURFACE, and every entry in the two declared lists below is
|
||||
//! where the reading of the demand got written down.
|
||||
//!
|
||||
//! When re-reading that demand: those corpora hold addons that *run on* 1.12, not addons written
|
||||
//! only for it — most of the big ones are multi-client and pick a code path at load, and they come
|
||||
//! in several versions of themselves. `scripts/api-coverage.sh`'s header has the detail; decision
|
||||
//! 2146 is the case that made it matter.
|
||||
//!
|
||||
//! ## The reference side is a CHAIN, not 23 flat surfaces
|
||||
//!
|
||||
//! The reference's 23 widget registrar tables are mutually **disjoint** — a class's real surface is
|
||||
//! its own table plus the tables it misses into. Comparing a live `Frame` against `0x878ec0` alone
|
||||
//! reports 87 of its 88 methods as beyond-1.12, which is how you get a gate nobody believes.
|
||||
//! [`WIDGET_CHAINS`] is that inheritance, and its two non-obvious rows are byte-settled, not
|
||||
//! guessed: `0x87c9b8` is the base `Region` map (its 19 names are exactly [`super::super`]'s
|
||||
//! `REGION_MAP_METHODS`), and `0x847ce4` is `LootButton`, whose whole surface of its own is
|
||||
//! `SetSlot` (decision 1799, read off the registrar's own `mov edx,1`).
|
||||
//! The widget-method surface gate, the widget sibling of [`super::reference_surface`]. The
|
||||
//! reference's 23 widget registrar tables are disjoint, so a class's surface is its own table plus
|
||||
//! those it inherits ([`WIDGET_CHAINS`]): `0x87c9b8` is the base `Region` map (the 19
|
||||
//! `REGION_MAP_METHODS`), and `LootButton`'s own `0x847ce4` holds only `SetSlot`.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::script::{widget_method_census, UiScript};
|
||||
|
||||
/// `(class, its own registrar table, the tables it inherits)`.
|
||||
///
|
||||
/// `WorldFrame` is a `Frame` to Lua (1983/1984) and `TitleRegion` a bare `Region`, so neither has a
|
||||
/// table of its own among the 23. `TaxiRouteFrame` is absent for the same reason (1828).
|
||||
/// `(class, its own registrar table, the tables it inherits)`. `WorldFrame` is a `Frame` to Lua
|
||||
/// and `TitleRegion` a bare `Region`; like `TaxiRouteFrame`, neither has a table of its own.
|
||||
const WIDGET_CHAINS: &[(&str, &str, &[&str])] = &[
|
||||
("Frame", FRAME, &[REGION]),
|
||||
("WorldFrame", FRAME, &[REGION]),
|
||||
@@ -70,8 +39,7 @@ const WIDGET_CHAINS: &[(&str, &str, &[&str])] = &[
|
||||
("Minimap", "0x84c538", FRAME_CHAIN),
|
||||
("Texture", "0x87c128", &[REGION]),
|
||||
("FontString", "0x87c1d8", &[REGION]),
|
||||
// The font OBJECT (`CreateFont`) carries its own `GetName`/`GetObjectType`/`IsObjectType` and
|
||||
// inherits nothing.
|
||||
// The font object (`CreateFont`) inherits nothing.
|
||||
("Font", "0x87c7c8", &[]),
|
||||
("TitleRegion", REGION, &[]),
|
||||
];
|
||||
@@ -82,17 +50,11 @@ const MODEL: &str = "0x878948";
|
||||
const PLAYER_MODEL: &str = "0x84f1fc";
|
||||
const FRAME_CHAIN: &[&str] = &[FRAME, REGION];
|
||||
|
||||
/// Methods the reference registers that this client does not answer — **a list that may only
|
||||
/// shrink**, one row per name, naming every class it is missing on and why it has not been built.
|
||||
///
|
||||
/// The corpus demand behind each is 2142's census. A name with real, live callers does not belong
|
||||
/// here: it belongs implemented.
|
||||
/// Reference methods this client does not answer yet: one row per name, its classes and why. The
|
||||
/// list only shrinks; a name with live callers belongs implemented.
|
||||
const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
// ── The MovieFrame trio: zero addon demand, and the stock GLUE screen calls all three ──────
|
||||
// `_extracted_gluexml/MovieFrame.lua:23/30/35/47/71`. The glue screens stay ours permanently
|
||||
// (0068 §8), so the intro-movie screen is owed these however little the ecosystem wants them —
|
||||
// which makes this the one group here that is a real backlog item rather than a name nobody
|
||||
// has ever typed. benilla has no movie playback at all, so it is a feature, not a verb.
|
||||
// ── The MovieFrame trio ──
|
||||
// The stock glue `MovieFrame.lua` calls all three; benilla plays no movie yet.
|
||||
(
|
||||
"StartMovie",
|
||||
"MovieFrame",
|
||||
@@ -104,10 +66,8 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
"MovieFrame",
|
||||
"the same — `MovieFrame.lua:35` passes `GetMovieSubtitles()`",
|
||||
),
|
||||
// ── The Minimap's four content setters ────────────────────────────────────────────────────
|
||||
// benilla draws the minimap's blips, arrow and mask from the app side (`minimap/blips`), never
|
||||
// through Lua; the stock UI does not call these either. Wiring them means routing the art
|
||||
// choice back through the VM, which nothing has asked for.
|
||||
// ── The Minimap's four content setters ──
|
||||
// The app draws the minimap's blips and arrow itself, never through Lua.
|
||||
(
|
||||
"SetArrowModel",
|
||||
"Minimap",
|
||||
@@ -116,10 +76,8 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
("SetBlipTexture", "Minimap", "the same"),
|
||||
("SetIconTexture", "Minimap", "the same"),
|
||||
("SetPlayerModel", "Minimap", "the same"),
|
||||
// ── The Button font-object and text-colour family ─────────────────────────────────────────
|
||||
// Seven getters and one setter over a button's disabled/highlight/pushed font state. benilla
|
||||
// keeps that state (`button.rs`'s `Slot` triple) but publishes none of it back. **Zero call
|
||||
// sites in 329 addon folders and zero in the stock UI** — the whole family is completeness.
|
||||
// ── The Button font-object and text-colour family ──
|
||||
// `button.rs` keeps this font state but publishes none of it; nothing calls these.
|
||||
(
|
||||
"GetDisabledFontObject",
|
||||
"Button CheckButton LootButton",
|
||||
@@ -155,9 +113,7 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
"Button CheckButton LootButton",
|
||||
"the same family",
|
||||
),
|
||||
// ── Line spacing, on the five classes that carry text ─────────────────────────────────────
|
||||
// A real layout property benilla's text layout does not model — a setter here is not a store,
|
||||
// it is a change to how `ui_text` lays out wrapped lines. No caller anywhere measured.
|
||||
// ── Line spacing, on the five classes that carry text ──
|
||||
(
|
||||
"GetSpacing",
|
||||
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
|
||||
@@ -168,9 +124,7 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
|
||||
"the same — the setter is a layout change, not a stored value",
|
||||
),
|
||||
// ── EditBox mode read-backs ───────────────────────────────────────────────────────────────
|
||||
// The four modes are all SET here (`editbox`'s `SetAutoFocus`/`SetMultiLine`/`SetNumeric`/
|
||||
// `SetPassword`) and none is readable. Cheap, and nothing has ever asked.
|
||||
// ── EditBox mode read-backs ──
|
||||
(
|
||||
"IsAutoFocus",
|
||||
"EditBox",
|
||||
@@ -179,10 +133,7 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
("IsMultiLine", "EditBox", "the same"),
|
||||
("IsNumeric", "EditBox", "the same"),
|
||||
("IsPassword", "EditBox", "the same"),
|
||||
// ── ScrollingMessageFrame's line-position surface ─────────────────────────────────────────
|
||||
// The scroll POSITION verbs (`ScrollUp`/`ScrollDown`/`ScrollToTop`…) are all here; these four
|
||||
// report or set where that position sits, which our message buffer tracks internally and does
|
||||
// not publish. No caller anywhere measured.
|
||||
// ── ScrollingMessageFrame's line-position surface ──
|
||||
(
|
||||
"GetCurrentLine",
|
||||
"ScrollingMessageFrame",
|
||||
@@ -191,16 +142,14 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
("GetCurrentScroll", "ScrollingMessageFrame", "the same"),
|
||||
("GetNumLinesDisplayed", "ScrollingMessageFrame", "the same"),
|
||||
("SetScrollFromBottom", "ScrollingMessageFrame", "the same"),
|
||||
// ── The colour picker's HSV pair ──────────────────────────────────────────────────────────
|
||||
// `ColorSelect` publishes the RGB pair; the HSV twin would need the conversion the reference
|
||||
// does in C. The stock `ColorPickerFrame` uses RGB only, and so does every corpus caller.
|
||||
// ── The colour picker's HSV pair ──
|
||||
(
|
||||
"GetColorHSV",
|
||||
"ColorSelect",
|
||||
"the RGB pair is published; nothing measured asks for HSV",
|
||||
),
|
||||
("SetColorHSV", "ColorSelect", "the same"),
|
||||
// ── One texture read-back ─────────────────────────────────────────────────────────────────
|
||||
// ── One texture read-back ──
|
||||
(
|
||||
"IsDesaturated",
|
||||
"Texture",
|
||||
@@ -208,9 +157,7 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// Methods this client answers that 1.12 does not register on that class — **each a deliberate,
|
||||
/// reasoned exception, never a quiet superset**. Same contract as
|
||||
/// [`super::reference_surface::allowed_beyond_1_12`]: a name here must say why it stays.
|
||||
/// Methods answered here that 1.12 does not register on that class, each with why it stays.
|
||||
const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[];
|
||||
|
||||
/// The reference's widget rows, `table_va -> {name}`.
|
||||
@@ -236,7 +183,7 @@ fn reference_tables() -> BTreeMap<String, BTreeSet<String>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `class -> the effective reference surface` (own table + everything it inherits).
|
||||
/// `class -> the effective reference surface` (its own table and everything it inherits).
|
||||
fn reference_surface_by_class() -> BTreeMap<&'static str, BTreeSet<String>> {
|
||||
let tables = reference_tables();
|
||||
assert!(
|
||||
@@ -284,8 +231,7 @@ fn declared(list: &[(&str, &str, &str)]) -> BTreeMap<String, BTreeSet<String>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render a `class -> names` map as the declared-list rows it would need, so a failure hands you
|
||||
/// the entry to paste rather than a set difference to transcribe.
|
||||
/// Render a `class -> names` map as declared-list rows, ready to paste in.
|
||||
fn as_rows(diff: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
let mut by_name: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
|
||||
for (class, names) in diff {
|
||||
@@ -343,7 +289,7 @@ fn every_1_12_widget_method_is_answered() {
|
||||
as_rows(&missing)
|
||||
);
|
||||
|
||||
// A listed name that came back fails, so the list cannot outlive its fix.
|
||||
// A listed name that is answered now fails, so the list cannot outlive its fix.
|
||||
let mut healed: Vec<String> = Vec::new();
|
||||
for (name, classes, _) in NOT_YET_ANSWERED {
|
||||
for class in classes.split_whitespace() {
|
||||
@@ -374,8 +320,7 @@ fn our_widget_methods_stay_inside_the_1_12_surface() {
|
||||
let extra: BTreeSet<String> = have
|
||||
.iter()
|
||||
.filter(|n| !want.contains(*n))
|
||||
// Our host bridge and the VM's own internals declare themselves ours by prefix, the
|
||||
// same discipline `reference_surface` uses for globals.
|
||||
// Our host bridge and VM internals are marked by prefix, as `reference_surface` does.
|
||||
.filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_"))
|
||||
.filter(|n| !listed.get(*n).is_some_and(|c| c.contains(class)))
|
||||
.cloned()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The `WorldFrame` kind (decisions 1983/1984; vtable `0x8043e8`): a `Frame` to Lua,
|
||||
//! a one-shot type, born in stratum `WORLD` with mouse and wheel enabled, and a hit the app's
|
||||
//! arbiter must be able to tell apart.
|
||||
//! The `WorldFrame` kind (vtable `0x8043e8`): a `Frame` to Lua, creatable once, born in stratum
|
||||
//! `WORLD` with mouse and wheel on, and a hit the app can tell apart.
|
||||
|
||||
use crate::script::UiScript;
|
||||
|
||||
@@ -13,9 +12,8 @@ fn vm() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// `GetObjectType()` answers `"Frame"` and `IsObjectType("WorldFrame")` is nil: the class
|
||||
/// inherits the base table's type slots (the TaxiRouteFrame precedent), so the registered name
|
||||
/// never becomes an identity.
|
||||
/// `GetObjectType()` answers `"Frame"`: the class keeps the base table's type slots, as
|
||||
/// `TaxiRouteFrame` does.
|
||||
#[test]
|
||||
fn the_world_frame_is_a_frame_to_lua() {
|
||||
let s = vm();
|
||||
@@ -72,8 +70,6 @@ fn the_world_frame_is_born_in_stratum_world_with_the_mouse_and_wheel() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The hit test answers the world frame where nothing else is under the cursor — the addon-facing
|
||||
/// scripts on it fire — and the app can tell that hit apart from a UI hit.
|
||||
#[test]
|
||||
fn a_hit_on_the_world_frame_is_told_apart_from_a_ui_hit() {
|
||||
let mut s = vm();
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
//! The world-map bindings (decision 0203 phase 2): catalog/feed pushes, the engine-owned
|
||||
//! selection, and the deferred WORLD_MAP_UPDATE queue.
|
||||
//! The world-map bindings: catalog and feed pushes, the engine-owned selection, and the deferred
|
||||
//! `WORLD_MAP_UPDATE` queue.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A two-continent catalog shaped like the real 5875 one (Kalimdor first — the app's push order
|
||||
/// defines every Lua-visible index; the sheet rects are the real disjoint kernel outputs). The
|
||||
/// EK zone grid is sparse: exactly the real Goldshire cell (12735, verified against the shipped
|
||||
/// Azeroth.zmp) marked as zone 1.
|
||||
/// A two-continent catalog shaped like the real one: Kalimdor first, since the push order defines
|
||||
/// every Lua-visible index, with the real disjoint sheet rects. The sparse EK grid marks the real
|
||||
/// Goldshire cell (12735 in `Azeroth.zmp`) as zone 1.
|
||||
fn push_catalog(s: &mut UiScript) {
|
||||
let mut ek_grid = vec![0u16; 128 * 128];
|
||||
ek_grid[12735] = 1;
|
||||
// Cell 8801 = the child (0.5, 0.5) resolves to through Elwynn's OWN loc rect — a "Stormwind
|
||||
// City" peer child. Clicking it from the Elwynn zone map drills into the city (`0x4a67a0`).
|
||||
// Cell 8801 is where (0.5, 0.5) lands through Elwynn's own loc rect: the Stormwind City peer,
|
||||
// which a click on the Elwynn map drills into (`0x4a67a0`).
|
||||
ek_grid[8801] = 2;
|
||||
// Cell 9060 = the child (0.82, 0.82) resolves to through Elwynn's own rect — Elwynn itself,
|
||||
// the case the zone-level hover must stay silent on (the displayed zone never names itself).
|
||||
// Cell 9060, where (0.82, 0.82) lands: Elwynn itself, which the zone-level hover never names.
|
||||
ek_grid[9060] = 1;
|
||||
s.set_world_map_catalog(vec![
|
||||
WorldMapContinentView {
|
||||
@@ -52,15 +50,11 @@ fn push_catalog(s: &mut UiScript) {
|
||||
name: "Elwynn Forest".into(),
|
||||
area_id: 12,
|
||||
map_file: "Elwynn".into(),
|
||||
// A clean synthetic loc rect inside the EK continent rect: w=2000, h=1500 → the
|
||||
// continent-highlight formula lands on round-ish expected coords (asserted below).
|
||||
// Synthetic, inside EK's rect: w=2000, h=1500, for round highlight coords.
|
||||
loc_rect: (-8000.0, -10000.0, -400.0, -1900.0),
|
||||
// Three overlays. Two are shaped like real Elwynn rows: Northshire reveals
|
||||
// on explore bit 125, Goldshire on bit 124 (the real AreaTable exploreFlags);
|
||||
// their hit rects (top, left, bottom, right; px of 1002×668) sit apart so a
|
||||
// point is in at most one. The first is a row whose first area slot resolves
|
||||
// to nothing (bit 126; its rect overlaps Northshire's) — the zone-level hover
|
||||
// must walk past it, not stop at it.
|
||||
// Northshire (bit 125) and Goldshire (124) carry the real exploreFlags; hit
|
||||
// rects are (top, left, bottom, right) in px of 1002×668. The first row's area
|
||||
// is unresolvable (bit 126) and overlaps Northshire: the hover walks past it.
|
||||
overlays: vec![
|
||||
WorldMapOverlayView {
|
||||
texture: "Interface\\WorldMap\\Elwynn\\STONECAIRNLAKE".into(),
|
||||
@@ -94,8 +88,7 @@ fn push_catalog(s: &mut UiScript) {
|
||||
},
|
||||
],
|
||||
},
|
||||
// A city peer of Elwynn — a WorldMapArea child of the continent with its own art
|
||||
// folder, indistinguishable from a zone in code (`0x4a67a0`).
|
||||
// A city peer of Elwynn, which the reference treats as a zone (`0x4a67a0`).
|
||||
WorldMapZoneView {
|
||||
name: "Stormwind City".into(),
|
||||
area_id: 1519,
|
||||
@@ -108,19 +101,16 @@ fn push_catalog(s: &mut UiScript) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// The **orphan list** — `0x4a5d00`'s third array, and in 5875 its entire population: the three
|
||||
/// battlegrounds, with the real row ids, map ids, area ids and art folders, in `WorldMapArea.dbc`
|
||||
/// **file order** (the fill pass appends with no sort). Alterac Valley carries one of its three
|
||||
/// real `WorldMapOverlay` rows over its real AreaBit 954 — the only battleground art the
|
||||
/// explored-bit gate has anything to say about (WSG and AB key zero overlay rows).
|
||||
/// The orphan list, `0x4a5d00`'s third array: in 1.12 exactly the three battlegrounds, with their
|
||||
/// real row, map and area ids and art folders, in `WorldMapArea.dbc` file order (no sort). Alterac
|
||||
/// Valley carries one of its real `WorldMapOverlay` rows (AreaBit 954); WSG and AB have none.
|
||||
fn push_direct_areas(s: &mut UiScript) {
|
||||
let row = |name: &str, area_id: u32, folder: &str, overlays: Vec<WorldMapOverlayView>| {
|
||||
WorldMapZoneView {
|
||||
name: name.into(),
|
||||
area_id,
|
||||
map_file: folder.into(),
|
||||
// A synthetic rect: nothing engine-side projects through it (the app owns every
|
||||
// projection), it is carried because the row is the row.
|
||||
// A synthetic rect: the app owns every projection, so nothing here reads it.
|
||||
loc_rect: (1500.0, 500.0, 1600.0, 600.0),
|
||||
overlays,
|
||||
}
|
||||
@@ -149,20 +139,17 @@ fn push_direct_areas(s: &mut UiScript) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// **The direct-area selection** — the third state, and the whole of what a battleground map is
|
||||
/// (`[0x845074]`). Inside Warsong Gulch the
|
||||
/// reference selects `(continent = -2, direct = WorldMapArea 443)`, and `GetMapInfo()` answers the
|
||||
/// art folder that `Blizzard_BattlefieldMinimap.lua:83-86` needs before it will draw anything at
|
||||
/// all; a client that models the selection as `(continent, zone)` answers nil there and draws an
|
||||
/// empty battle map.
|
||||
/// The direct-area selection (`[0x845074]`) is a third state: inside Warsong Gulch the reference
|
||||
/// selects `(continent = -2, direct = WorldMapArea 443)`, and `GetMapInfo()` answers the art
|
||||
/// folder `Blizzard_BattlefieldMinimap.lua:83` needs before it draws.
|
||||
#[test]
|
||||
fn a_direct_area_is_the_third_selection_state() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
push_direct_areas(&mut s);
|
||||
|
||||
// The player is in Warsong Gulch: `0x4a6650`'s continent loop misses (map 489 is no
|
||||
// continent's) and only then does the orphan loop match, selecting the row's own id.
|
||||
// In Warsong Gulch `0x4a6650`'s continent loop misses (map 489 is no continent's) and the
|
||||
// orphan loop selects the row's own id.
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
|
||||
s.set_world_map_player_direct_area(Some(443));
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
@@ -188,9 +175,8 @@ fn a_direct_area_is_the_third_selection_state() {
|
||||
row id has no entries to fire from"
|
||||
);
|
||||
|
||||
// Hover and click are refused. Both matter: there is no `.zmp` bitmap for a battleground
|
||||
// (`0x4a6ec0`/`0x4a7620`/`0x4a7540` each test `-2` explicitly), and the world sheet's
|
||||
// continent walk must not run just because the direct state shares its `continent == 0` cell.
|
||||
// Hover and click are refused: a battleground has no `.zmp` bitmap (`0x4a6ec0`, `0x4a7620`,
|
||||
// `0x4a7540` each test `-2`), and the world sheet's walk must not run on `continent == 0`.
|
||||
assert!(s
|
||||
.eval::<Option<String>>("return UpdateMapHighlight(0.5, 0.5)")
|
||||
.unwrap()
|
||||
@@ -203,10 +189,9 @@ fn a_direct_area_is_the_third_selection_state() {
|
||||
"a click cannot drill off an instance map"
|
||||
);
|
||||
|
||||
// ── THE REFRESH TRAP. All five of the reference's in-place refresh sites re-pass
|
||||
// `direct != -1 ? direct : zone` (`0x48f9f6`, `0x4a6460`, `0x6d93d8`, `0x6d9a17`, `0x6dac72`);
|
||||
// one that re-passed the zone alone would drop the instance map back to the world view on the
|
||||
// next world-state push or exploration update — silently, and only inside a battleground.
|
||||
// All five of the reference's refresh sites re-pass `direct != -1 ? direct : zone`
|
||||
// (`0x48f9f6`, `0x4a6460`, `0x6d93d8`, `0x6d9a17`, `0x6dac72`); passing the zone alone would
|
||||
// drop an instance map to the world view on the next refresh.
|
||||
s.set_world_map_explored(vec![u32::MAX; 64]);
|
||||
s.set_world_map_landmarks(Vec::new());
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
@@ -218,8 +203,8 @@ fn a_direct_area_is_the_third_selection_state() {
|
||||
"a refresh re-selects the DIRECT cell, never the (0, 0) pair beside it"
|
||||
);
|
||||
|
||||
// Selecting a continent or a zone CLEARS the direct cell: `0x4a67a0`'s other legs all fall
|
||||
// into `0x4a67ea mov [0x845074],-1`, and only `ecx == -2` jumps past it.
|
||||
// Selecting a continent or zone clears the direct cell: `0x4a67a0`'s other legs all reach
|
||||
// `0x4a67ea`, which writes -1 to `[0x845074]`; only `ecx == -2` jumps past it.
|
||||
s.run("SetMapZoom(2, 1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64, i64)>(
|
||||
@@ -230,8 +215,8 @@ fn a_direct_area_is_the_third_selection_state() {
|
||||
"the instance map does not shadow later navigation"
|
||||
);
|
||||
|
||||
// And the stock zoom-out button's own path off a battleground map: its `else` arm fires
|
||||
// because `GetCurrentMapZone()` is 0 there, and `SetMapZoom(0)` is the world view.
|
||||
// The stock zoom-out button off a battleground map: `GetCurrentMapZone()` is 0 there, so its
|
||||
// `else` arm runs `SetMapZoom(0)`, the world view (`WorldMapFrame.lua:257`).
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
s.run("SetMapZoom(0)").unwrap();
|
||||
assert_eq!(
|
||||
@@ -242,10 +227,8 @@ fn a_direct_area_is_the_third_selection_state() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An instance map's overlays are **admitted**, keyed by the direct area, under exactly the zone's
|
||||
/// explored-bit gate (`0x4a67a0`'s overlay half, `0x4a6b10 jl 0x4a6b3d`) — no free reveal for a
|
||||
/// battleground. On 5875 data that means Alterac Valley's three rows and nothing at all for the
|
||||
/// other two.
|
||||
/// An instance map's overlays, keyed by the direct area, pass the zone's explored-bit gate
|
||||
/// (`0x4a67a0`'s overlay half, `0x4a6b10` to `0x4a6b3d`), with no free reveal for a battleground.
|
||||
#[test]
|
||||
fn an_instance_map_reveals_its_overlays_under_the_zone_gate() {
|
||||
let mut s = script();
|
||||
@@ -270,18 +253,15 @@ fn an_instance_map_reveals_its_overlays_under_the_zone_gate() {
|
||||
"Interface\\WorldMap\\AlteracValley\\DUNBALDAR"
|
||||
);
|
||||
|
||||
// Warsong Gulch keys zero `WorldMapOverlay` rows — a fully explored character still sees bare
|
||||
// detail tiles there.
|
||||
// Warsong Gulch keys no `WorldMapOverlay` rows, so even fully explored it shows none.
|
||||
s.set_world_map_player_direct_area(Some(443));
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
s.set_world_map_explored(vec![u32::MAX; 64]);
|
||||
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The navigation surface: lists come from the catalog, SetMapZoom's selection reads back
|
||||
/// synchronously in the same Lua breath (the reference's dropdown-click contract), zone/continent
|
||||
/// arguments clamp, and GetMapInfo names the displayed art folder (nil at the world level — the
|
||||
/// reference Lua's own "World" fallback).
|
||||
/// Lists come from the catalog, `SetMapZoom` reads back in the same call stack as the reference's
|
||||
/// dropdown expects, arguments clamp, and `GetMapInfo` names the art folder (nil at world level).
|
||||
#[test]
|
||||
fn worldmap_navigation_and_map_info() {
|
||||
let mut s = script();
|
||||
@@ -306,7 +286,6 @@ fn worldmap_navigation_and_map_info() {
|
||||
("Durotar".into(), "The Barrens".into())
|
||||
);
|
||||
|
||||
// World level by default: continent 0, nil map info.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
.unwrap(),
|
||||
@@ -314,7 +293,6 @@ fn worldmap_navigation_and_map_info() {
|
||||
);
|
||||
assert!(s.eval::<bool>("return GetMapInfo() == nil").unwrap());
|
||||
|
||||
// The synchronous read-back the reference relies on, and the art-folder name per level.
|
||||
s.run("SetMapZoom(2)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, String)>(
|
||||
@@ -330,7 +308,6 @@ fn worldmap_navigation_and_map_info() {
|
||||
"a zone selection names the ZONE's art folder"
|
||||
);
|
||||
|
||||
// Out-of-range arguments clamp (never a Lua error — addons pass garbage).
|
||||
s.run("SetMapZoom(9, 9)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
@@ -340,9 +317,9 @@ fn worldmap_navigation_and_map_info() {
|
||||
);
|
||||
}
|
||||
|
||||
/// SetMapToCurrentZone lands on the app-fed player zone; the feed's projection + facing surface
|
||||
/// through GetPlayerMapPosition/GetPlayerFacing — for `"player"` and, since report B320, for the
|
||||
/// `party1..4` slots too. A `raid` token still answers the off-map sentinel.
|
||||
/// `SetMapToCurrentZone` lands on the app-fed player zone, and the feed surfaces through
|
||||
/// `GetPlayerFacing` and `GetPlayerMapPosition` for the player and `party1..4`. A `raid` token
|
||||
/// answers the off-map sentinel; the reference reads raid positions (`WorldMapFrame.lua:379`).
|
||||
#[test]
|
||||
fn worldmap_current_zone_and_player_feed() {
|
||||
let mut s = script();
|
||||
@@ -389,20 +366,14 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The **engine** moves the selection too, with no Lua in the loop — the reference's second
|
||||
/// writer of `[0x84506c]`/`[0x845070]`: `0x494780`'s `old == 0` side call to the resolver
|
||||
/// `0x4a6650`, which every exit of ends in the `SetMap` setter `0x4a67a0`.
|
||||
///
|
||||
/// We only ever had the Lua writers, so until something opened the map we sat at the world
|
||||
/// level — and there `GetPlayerMapPosition` answers a world-SHEET uv, which every addon built on
|
||||
/// Astrolabe rescales as a zone uv. This pins that a fresh session can be on the player's own
|
||||
/// zone before a single line of FrameXML or addon Lua has asked for it.
|
||||
/// The engine moves the selection with no Lua in the loop: the reference's second writer of
|
||||
/// `[0x84506c]`/`[0x845070]` is `0x494780`'s `old == 0` call to the resolver `0x4a6650`, whose
|
||||
/// every exit ends in the `SetMap` setter `0x4a67a0`.
|
||||
#[test]
|
||||
fn the_engine_can_select_a_zone_with_no_lua_call() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
|
||||
// A fresh VM is the world sheet, and nothing Lua-side has run.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
.unwrap(),
|
||||
@@ -417,8 +388,7 @@ fn the_engine_can_select_a_zone_with_no_lua_call() {
|
||||
(1, 2),
|
||||
"the engine's own SetMap is what Lua reads back"
|
||||
);
|
||||
// The WHOLE selection moved, not just the pair of globals: GetMapInfo names the zone sheet,
|
||||
// which is what `WorldMapFrame_Update` loads art from and what Astrolabe keys its scale on.
|
||||
// The whole selection moved: `GetMapInfo` names the zone sheet `WorldMapFrame_Update` loads.
|
||||
assert!(
|
||||
s.eval::<Option<String>>("return GetMapInfo()")
|
||||
.unwrap()
|
||||
@@ -426,8 +396,7 @@ fn the_engine_can_select_a_zone_with_no_lua_call() {
|
||||
"a selected zone names its map file; the world level is the nil that mis-scales"
|
||||
);
|
||||
|
||||
// It clamps through the same tail as the Lua verbs — an out-of-range pair cannot corrupt
|
||||
// the selection (`0x4a67a0` range-checks the zone against the continent's child count).
|
||||
// It clamps through the same tail as the Lua verbs (`0x4a67a0` range-checks the zone).
|
||||
s.sync_world_map_to_player_zone(99, 99, None);
|
||||
let (c, _) = s
|
||||
.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
@@ -435,9 +404,9 @@ fn the_engine_can_select_a_zone_with_no_lua_call() {
|
||||
assert!(c > 0, "clamped into the catalog, never past it");
|
||||
}
|
||||
|
||||
/// World-level ProcessMapClick picks the continent whose sheet-rect contains the click (the
|
||||
/// 0x4a7100 AABB walk — the real kernel rects are disjoint); continent-level clicks resolve
|
||||
/// through the 0x4a6ec0 zone grid; hover names ride the same cell law.
|
||||
/// A world-level `ProcessMapClick` picks the continent whose sheet rect contains it (`0x4a7100`,
|
||||
/// the real rects being disjoint); continent-level clicks and hovers resolve through the zone grid
|
||||
/// (`0x4a6ec0`).
|
||||
#[test]
|
||||
fn worldmap_click_containment_and_zone_grid() {
|
||||
let mut s = script();
|
||||
@@ -460,10 +429,9 @@ fn worldmap_click_containment_and_zone_grid() {
|
||||
s.run("ProcessMapClick(0.72, 0.63)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 2);
|
||||
|
||||
// Continent level: Goldshire's UV inside the EK rect lands on grid cell 12735 (the real
|
||||
// Azeroth.zmp index for that world position) → zone 1. The zone lights up: name + fileName +
|
||||
// the six geometry values (`0x4a8494`), all against the zone's loc rect
|
||||
// within the continent. cont_w=35199.9, cont_h=23466.6; w=2000, h=1500.
|
||||
// Continent level: Goldshire's uv lands on cell 12735, zone 1, which lights up with its name,
|
||||
// file name and six geometry values (`0x4a8494`) against its loc rect in the continent:
|
||||
// cont_w=35199.9, cont_h=23466.6; w=2000, h=1500.
|
||||
let (name, file, tpx, tpy, tx, ty, sx, sy) = s
|
||||
.eval::<(String, String, f64, f64, f64, f64, f64, f64)>(
|
||||
"return UpdateMapHighlight(0.452843, 0.720880)",
|
||||
@@ -511,10 +479,8 @@ fn worldmap_click_containment_and_zone_grid() {
|
||||
"the grid click drills into the zone"
|
||||
);
|
||||
|
||||
// Now at the ZONE level (Elwynn): a click re-expressed through ELWYNN's own rect lands on cell
|
||||
// 8801 = the Stormwind City peer child → drills into the city map. This is the path that was a
|
||||
// no-op before (`0x4a75c9`: continent- and zone-level clicks run identical code, only the
|
||||
// windowing rect differs).
|
||||
// At zone level a click through Elwynn's own rect lands on cell 8801, the Stormwind City peer,
|
||||
// and drills in: `0x4a75c9` runs continent- and zone-level clicks alike, windowed differently.
|
||||
s.run("ProcessMapClick(0.5, 0.5)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
@@ -529,12 +495,10 @@ fn worldmap_click_containment_and_zone_grid() {
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// At ZONE level `UpdateMapHighlight` answers a NAME only (report B360; `0x4a812e`):
|
||||
/// a revealed overlay's sub-area when the cursor is inside its hit rect, else the neighbouring
|
||||
/// zone or city whose grid cell the cursor is in through the DISPLAYED zone's rect window — never
|
||||
/// the displayed zone itself — and always a nil fileName + six zeros, so the stock frame hides the
|
||||
/// highlight quad. Fogged sub-areas have no name; an overlay with no resolvable first area is
|
||||
/// walked past. The reference's own Duskwood shot: "Deadwind Pass" in the label, no highlight.
|
||||
/// At zone level `UpdateMapHighlight` answers a name only (`0x4a812e`): a revealed overlay's
|
||||
/// sub-area under the cursor, else the neighbouring zone or city whose cell the cursor is in
|
||||
/// through the displayed zone's rect, never that zone itself. The file name is nil and the
|
||||
/// geometry zeros, so the stock frame hides the quad.
|
||||
#[test]
|
||||
fn worldmap_zone_level_hover_names_without_highlight() {
|
||||
let mut s = script();
|
||||
@@ -550,20 +514,18 @@ fn worldmap_zone_level_hover_names_without_highlight() {
|
||||
|name: &str| -> Answer { (Some(name.into()), None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) };
|
||||
let miss: Answer = (None, None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
// Nothing explored. (0.5, 0.5) through Elwynn's rect is cell 8801 = the Stormwind City peer:
|
||||
// the neighbouring child is NAMED, and nothing else — the B360 answer, Deadwind Pass from
|
||||
// Duskwood in the report.
|
||||
// Nothing explored: (0.5, 0.5) is cell 8801, the Stormwind City peer, named and nothing else.
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Stormwind City"));
|
||||
// Cell 9060 = Elwynn itself: the displayed zone never names itself.
|
||||
assert_eq!(hover(&mut s, 0.82, 0.82), miss);
|
||||
// An empty cell, no overlay: the tail.
|
||||
assert_eq!(hover(&mut s, 0.2, 0.2), miss);
|
||||
// Inside Goldshire's hit rect but fogged (bit 124 clear): no name — the pre-search walks the
|
||||
// REVEALED list, and cell 8929 under it is empty.
|
||||
// Inside Goldshire's hit rect, fogged (bit 124 clear): the pre-search walks only revealed
|
||||
// overlays, and cell 8929 under it is empty.
|
||||
assert_eq!(hover(&mut s, 0.42, 0.7), miss);
|
||||
|
||||
// Reveal Northshire (bit 125 = word 3, bit 29). Its rect contains (0.5, 0.5): the overlay
|
||||
// pre-search runs BEFORE the grid, so the sub-area wins over the city under the same point.
|
||||
// Reveal Northshire (bit 125 = word 3, bit 29): the overlay pre-search runs before the grid,
|
||||
// so its sub-area wins over the city under (0.5, 0.5).
|
||||
let mut explored = vec![0u32; 64];
|
||||
explored[3] = 1 << 29;
|
||||
s.set_world_map_explored(explored.clone());
|
||||
@@ -585,8 +547,7 @@ fn worldmap_zone_level_hover_names_without_highlight() {
|
||||
s.set_world_map_explored(explored);
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Northshire Valley"));
|
||||
|
||||
// Control: back at the continent level the hovered zone still lights up — fileName is the art
|
||||
// folder and the quad geometry is non-zero.
|
||||
// Control: at continent level the hovered zone still lights up, with its art folder.
|
||||
s.run("SetMapZoom(2)").unwrap();
|
||||
let (name, file, tpx, _, tx, ty, _, _) = hover(&mut s, 0.452843, 0.720880);
|
||||
assert_eq!(
|
||||
@@ -596,8 +557,8 @@ fn worldmap_zone_level_hover_names_without_highlight() {
|
||||
assert!(tx > 0.0 && ty > 0.0);
|
||||
}
|
||||
|
||||
/// SetMapZoom queues WORLD_MAP_UPDATE through the pending-event queue: nothing fires inside the
|
||||
/// call (a binding can't re-enter dispatch), the registered frame hears it on the next tick.
|
||||
/// `SetMapZoom` queues `WORLD_MAP_UPDATE` rather than firing it, since a binding cannot re-enter
|
||||
/// dispatch; the registered frame hears it on the next tick.
|
||||
#[test]
|
||||
fn worldmap_update_event_fires_on_next_tick() {
|
||||
let mut s = script();
|
||||
@@ -629,16 +590,14 @@ fn worldmap_update_event_fires_on_next_tick() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The exploration fog: overlays reveal per the pushed bitset — none before any push, the
|
||||
/// matching subset after (bit 125 = Northshire in the fixture), the full info tuple comes back,
|
||||
/// and re-pushing the same bitset queues no extra repaint.
|
||||
/// Overlays reveal per the pushed explored bitset: none before a push, the matching subset after,
|
||||
/// each with its full info tuple.
|
||||
#[test]
|
||||
fn worldmap_overlays_reveal_by_explored_bits() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
s.run("SetMapZoom(2, 1)").unwrap(); // the Elwynn zone map (EK's only fixture zone)
|
||||
s.run("SetMapZoom(2, 1)").unwrap(); // the Elwynn zone map
|
||||
|
||||
// Nothing explored: the zone shows no overlays (all parchment).
|
||||
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
|
||||
|
||||
// Bit 125 set (word 3, bit 29): Northshire reveals, Goldshire stays fogged.
|
||||
@@ -661,7 +620,7 @@ fn worldmap_overlays_reveal_by_explored_bits() {
|
||||
.eval::<bool>("return GetMapOverlayInfo(3) == nil")
|
||||
.unwrap());
|
||||
|
||||
// At the continent level the overlay family reads empty (fog is a zone-map thing).
|
||||
// At continent level the overlay family reads empty: fog is a zone-map thing.
|
||||
s.run("SetMapZoom(2)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user