NTFS load order, a title-sorted AddOns list, and tooltips at the cursor
- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178) - GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175) - SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176) - a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176) - a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -692,9 +692,11 @@ fn main() {
|
||||
println!(
|
||||
"\n what was WARNED about ({addons} addons raised at least one, by addon count):"
|
||||
);
|
||||
for (w, count) in rows.into_iter().take(12) {
|
||||
println!(" {count:>4} {w}");
|
||||
}
|
||||
// Through `ranked`, not a bare `take(12)`: this list is a queue like every other one here,
|
||||
// and a silent cut reads as "that is the whole list" (1242's rule, which this block was
|
||||
// written outside of). It cost a measurement — asking the corpus how many `SetPoint`
|
||||
// targets fail to resolve, the answer sat below the cut and the column read zero.
|
||||
ranked(rows, 12);
|
||||
}
|
||||
|
||||
// The distribution, because a mean would hide the shape.
|
||||
|
||||
@@ -397,7 +397,11 @@ fn corpus(
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.filter(|n| manifest_path(root, n).is_some())
|
||||
.collect();
|
||||
names.sort();
|
||||
// **The live walk's order, not a second opinion.** This used to be a bare `names.sort()`,
|
||||
// a private copy of the ordering `ui_script::addons` decides — so the instrument could
|
||||
// measure a walk the client does not perform. It calls the one definition now, which is what
|
||||
// makes a `--together` row about shared-library provenance mean anything.
|
||||
crate::ui_script::addons::sort_by_directory_order(&mut names);
|
||||
|
||||
let installed: BTreeSet<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
|
||||
|
||||
@@ -1867,6 +1871,16 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
// — ten subscripts into a list that answered nothing, so `AucCore.lua:106` died on
|
||||
// `table index is nil` and took `BeanCounter`, `Informant`, `Enchantrix` and `FonzAppraiser`
|
||||
// with it. Five corpus addons read the tree; four of them at file scope.
|
||||
// **The `SMSG_ADDON_INFO` reply, hiding nothing** (2175) — the same class of state correction
|
||||
// as the two catalogues below and the empty spellbook above. The Lua index space
|
||||
// (`GetNumAddOns`, and the index form of every AddOn verb) is built only when the server
|
||||
// answers, and a surveyed VM is an IN-WORLD VM: the reference cannot be in the world without
|
||||
// having had that reply. A survey that left it unanswered would measure a client whose
|
||||
// `GetNumAddOns()` is 0, which is what AceAddon and AceLibrary scan.
|
||||
//
|
||||
// Empty, not the twelve: the corpus registry holds no `## Secure:` addon, so a real server
|
||||
// would have nothing to hide here.
|
||||
script.note_addon_info_reply(&[]);
|
||||
script.set_auction_item_classes(auction_item_classes());
|
||||
|
||||
// **The talent tree.** `GetTalentInfo(tab, i)` answers off `Talent.dbc` × `TalentTab.dbc`;
|
||||
@@ -3169,6 +3183,23 @@ pub fn normalise(raw: &str) -> String {
|
||||
normalise_error(raw)
|
||||
}
|
||||
|
||||
/// The byte index of the next `'` that actually OPENS a quote — one with a non-alphanumeric on at
|
||||
/// least one side. See [`normalise_error`] step 2.
|
||||
fn quote_open(s: &str) -> Option<usize> {
|
||||
let b = s.as_bytes();
|
||||
let mut from = 0usize;
|
||||
while let Some(rel) = s[from..].find('\'') {
|
||||
let i = from + rel;
|
||||
let prev_word = i > 0 && b[i - 1].is_ascii_alphanumeric();
|
||||
let next_word = b.get(i + 1).is_some_and(u8::is_ascii_alphanumeric);
|
||||
if !(prev_word && next_word) {
|
||||
return Some(i);
|
||||
}
|
||||
from = i + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One load error with everything addon-specific removed, so two addons hitting the same wall
|
||||
/// produce the same string. See [`blockers`] for why the crudeness is the point.
|
||||
fn normalise_error(raw: &str) -> String {
|
||||
@@ -3184,10 +3215,16 @@ fn normalise_error(raw: &str) -> String {
|
||||
// 2 · Every quoted name becomes `'X'` — the name is what varies between two addons that hit
|
||||
// the same wall, and it is already ranked by `demand`. Both quote kinds, because mlua
|
||||
// writes a chunk name as `[string "MyFrame:OnLoad"]`.
|
||||
//
|
||||
// **An APOSTROPHE INSIDE A WORD is not a quote.** The reference's own diagnostics are
|
||||
// written in English — `Couldn't find region named '%s'`, `Couldn't find relative frame:
|
||||
// %s` — and the `'` in `Couldn't` opened a quote that then swallowed the rest of the line,
|
||||
// so the row read `Couldn'X'` and named nothing. Letters on both sides is the whole test:
|
||||
// a real opening quote is preceded by a space, a bracket or the line start.
|
||||
let squashed = core.replace('"', "'");
|
||||
let mut collapsed = String::with_capacity(squashed.len());
|
||||
let mut rest = squashed.as_str();
|
||||
while let Some(open) = rest.find('\'') {
|
||||
while let Some(open) = quote_open(rest) {
|
||||
collapsed.push_str(&rest[..open]);
|
||||
collapsed.push_str("'X'");
|
||||
match rest[open + 1..].find('\'') {
|
||||
|
||||
@@ -244,8 +244,9 @@ mod tests {
|
||||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||||
std::fs::write(dir.join(file), body).unwrap();
|
||||
};
|
||||
// Loads FIRST (the walk is alphabetical) and puts the library in the one global state,
|
||||
// exactly as any of the seventy corpus packages that ship a copy of `Tablet-2.0` does.
|
||||
// Loads FIRST (`A` before `Z` under the walk's NTFS collation) and puts the library in
|
||||
// the one global state, exactly as any of the seventy corpus packages that ship a copy of
|
||||
// `Tablet-2.0` does.
|
||||
write(
|
||||
"AlphaShipsIt",
|
||||
"## Interface: 11200\nlib.lua\n",
|
||||
|
||||
@@ -246,9 +246,11 @@ impl AddonsPanel {
|
||||
self.staged.push(rows.iter().map(|a| a.enabled).collect());
|
||||
self.list = rows;
|
||||
} else {
|
||||
// Same folder, same deterministic (alphabetical) discovery, read
|
||||
// back-to-back: every call answers the same rows in the same order, only the
|
||||
// `enabled` bits differ. That is what lets ONE metadata list carry N columns.
|
||||
// Same folder, same deterministic discovery order, read back-to-back:
|
||||
// every call answers the same rows in the same order, only the `enabled` bits
|
||||
// differ. That is what lets ONE metadata list carry N columns. The order is
|
||||
// `addons::sort_by_directory_order` — NTFS's, the reference's own listing
|
||||
// order — and the point here is only that it is a pure function of the names.
|
||||
debug_assert_eq!(
|
||||
rows.len(),
|
||||
self.list.len(),
|
||||
|
||||
@@ -118,6 +118,7 @@ impl Plugin for NetPlugin {
|
||||
.insert_resource(PingShared(handles.ping))
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<SelfGuid>()
|
||||
.init_resource::<AddonInfoReply>()
|
||||
.init_resource::<PendingTransfer>()
|
||||
.init_resource::<NetStatus>()
|
||||
.init_resource::<DroppedOpcodes>()
|
||||
@@ -2427,6 +2428,17 @@ pub(crate) struct EnteredWorldMessage {
|
||||
pub(crate) tutorial_flags: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// **`SMSG_ADDON_INFO`'s verdict for the live session** (decision 2175) — the addons the server
|
||||
/// hid from the Lua index space, or `None` when it never answered our addon block.
|
||||
///
|
||||
/// A resource rather than a field on [`EnteredWorldMessage`], because of *when* it is needed: the
|
||||
/// index space has to exist before the first addon's file-scope code runs, and the world-entry UI
|
||||
/// load ([`crate::ui_script::lifecycle::load_ingame_ui_on_world_entry`]) is inside that same edge.
|
||||
/// A message read a frame later by some other feed would seat it after every addon had already
|
||||
/// asked. Rewritten on every login, so a second server's silence cannot inherit the first's answer.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct AddonInfoReply(pub(crate) Option<Vec<String>>);
|
||||
|
||||
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
|
||||
/// id. Read by [`crate::cinematic`], which owns the playback *and* the ack (decision 0196: the ack
|
||||
/// is not optional, only late).
|
||||
|
||||
@@ -137,7 +137,13 @@ pub(crate) fn apply_net_updates(
|
||||
MessageWriter<CharActionResultMessage>,
|
||||
MessageWriter<super::CharacterLoginFailedMessage>,
|
||||
),
|
||||
MessageWriter<EnteredWorldMessage>,
|
||||
// **The world-entry pair** — the tuple is at Bevy's 16-element ceiling, and these two are
|
||||
// one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that the entry's own UI
|
||||
// load reads before the first addon's file-scope code asks `GetNumAddOns()` (2175).
|
||||
(
|
||||
MessageWriter<EnteredWorldMessage>,
|
||||
ResMut<crate::net::AddonInfoReply>,
|
||||
),
|
||||
MessageWriter<LoggedOutMessage>,
|
||||
MessageWriter<super::SpeedChangeMessage>,
|
||||
// The two server-authored mover edges the controller both *applies* and *answers*, paired to
|
||||
@@ -544,7 +550,7 @@ pub(crate) fn apply_net_updates(
|
||||
mut worldports,
|
||||
(mut char_lists, mut realm_lists),
|
||||
(mut char_actions, mut char_login_failures),
|
||||
mut entered_world,
|
||||
(mut entered_world, mut addon_reply),
|
||||
mut logged_out,
|
||||
mut speed_changes,
|
||||
(mut move_modes, mut knockbacks),
|
||||
@@ -620,11 +626,14 @@ pub(crate) fn apply_net_updates(
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
addon_info,
|
||||
} => session::connected(
|
||||
guid,
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
addon_info,
|
||||
&mut addon_reply,
|
||||
&mut self_guid,
|
||||
&mut status,
|
||||
&mut names,
|
||||
|
||||
@@ -118,6 +118,8 @@ pub(super) fn connected(
|
||||
name: String,
|
||||
billing_time_rested: u32,
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
addon_info: Option<Vec<String>>,
|
||||
addon_reply: &mut crate::net::AddonInfoReply,
|
||||
self_guid: &mut SelfGuid,
|
||||
status: &mut NetStatus,
|
||||
names: &mut NameCache,
|
||||
@@ -129,6 +131,9 @@ pub(super) fn connected(
|
||||
info!("net: in world as {name} (guid {guid})");
|
||||
// Our own name came with the login — seed the cache so "player" never queries.
|
||||
names.insert_player(guid, name, None);
|
||||
// Seated before the world-entry UI load reads it (2175), and overwritten every login so a
|
||||
// server that answers nothing cannot inherit the previous one's verdict.
|
||||
addon_reply.0 = addon_info;
|
||||
entered_world.write(EnteredWorldMessage {
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
|
||||
@@ -802,6 +802,27 @@ fn run(
|
||||
|
||||
let billing_time_rested = session.billing_time_rested();
|
||||
let tutorial_flags = session.take_tutorial_flags();
|
||||
// `SMSG_ADDON_INFO`'s verdict, paired back against the block we sent — the reply carries no
|
||||
// names (decision 2175). No reply at all means no hidden addons *and* no Lua index space;
|
||||
// the distinction is the script's to draw, so both arrive as the same empty list here and
|
||||
// the "did it answer" bit rides separately.
|
||||
let statuses = session.take_addon_info();
|
||||
let addon_info = statuses.as_deref().map(|statuses| {
|
||||
benilla_protocol::messages::hidden_from_reply(
|
||||
statuses,
|
||||
&benilla_protocol::messages::STOCK_SECURE_ADDONS,
|
||||
)
|
||||
});
|
||||
match (&statuses, &addon_info) {
|
||||
(Some(statuses), Some(hidden)) => bevy::log::info!(
|
||||
"net: SMSG_ADDON_INFO — {} record(s), {} hidden from the AddOn index space",
|
||||
statuses.len(),
|
||||
hidden.len()
|
||||
),
|
||||
_ => bevy::log::warn!(
|
||||
"net: no SMSG_ADDON_INFO — GetNumAddOns() stays 0, as the reference's would"
|
||||
),
|
||||
}
|
||||
let (mut reader, writer) = session.into_split()?;
|
||||
if events_tx
|
||||
.send(SessionEvent::Connected {
|
||||
@@ -809,6 +830,7 @@ fn run(
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
addon_info,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -462,13 +462,6 @@ fn root_from(home: Option<PathBuf>) -> Option<PathBuf> {
|
||||
home.map(|h| h.join(ADDONS_DIR))
|
||||
}
|
||||
|
||||
/// Every third-party addon we can see, in load order: alphabetical by folder name.
|
||||
///
|
||||
/// Alphabetical is **our** choice, not a fidelity claim — the reference enumerates its own record
|
||||
/// list and we have not established that order. What matters is that it is deterministic, so two
|
||||
/// runs on one machine load the same interface. Real ordering constraints between addons are
|
||||
/// expressed by `## Dependencies:` and honoured by [`load_third_party`]'s recursion, which is the
|
||||
/// mechanism the reference actually uses.
|
||||
/// The reference's own LoadOnDemand addons, as its `Interface\\AddOns\\` folder names them — the
|
||||
/// twelve `Blizzard_*` folders on a 1.12 install, each a `.pub` decoy on disk with the real files
|
||||
/// inside the archive (the InspectUI header in the manifest). Probed against the chain, so a
|
||||
@@ -508,13 +501,119 @@ fn chain_addons() -> Vec<Addon> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every registered addon, in the reference's own **registration order**: the archive pass first,
|
||||
/// then the loose folders (decision 2175).
|
||||
///
|
||||
/// **Two passes, archive before loose, and the first pass wins a duplicate.** Byte-read in wow-re
|
||||
/// `system/ui/scratch/addon-registry-scan-and-order.md` §1–§5: `AddOn_ScanAddOnDir 0x51c760` runs
|
||||
/// `0x401470` over each mounted archive's `(listfile)` (`0x648fb0`, textual line order) and only
|
||||
/// then `0x42ad10`'s `FindFirstFileW` walk of the loose directories; both funnel into
|
||||
/// `0x51c9b0`, which probes the name hash and **returns immediately on a hit** (`0x51ca10`), and
|
||||
/// links each new record at the TAIL (`0x521ad0` mode 2 — a tail insert with no comparison of any
|
||||
/// kind, so registry order *is* registration order). §10 measures the consequence on a stock
|
||||
/// install: the twelve `Blizzard_*` `.toc`s are the only ones in any archive, so Blizzard occupies
|
||||
/// registry positions 1..12 and third-party always follows.
|
||||
///
|
||||
/// We had it the other way round — loose first, chain appended — which put our chain rows last and
|
||||
/// left a player folder named `Blizzard_TalentUI` registered *twice*, once from each source. The
|
||||
/// duplicate is the half with teeth: two records share one name, and every name lookup finds only
|
||||
/// the first.
|
||||
///
|
||||
/// The load walk barely notices (all twelve are `## LoadOnDemand: 1`, so `0x51f600` skips them),
|
||||
/// but the registry order is what the Lua index space is built from, and it is what decides which
|
||||
/// addon's copy of a shared global wins — the last writer, per §9.
|
||||
fn discover() -> Vec<Addon> {
|
||||
let mut found = discover_folder();
|
||||
found.extend(chain_addons());
|
||||
// PASS 1 — the archive. `BLIZZARD_ADDONS` is in ascending name order, which is also the order
|
||||
// the shipped `patch.MPQ` listfile happens to carry those twelve lines in (§10, measured). The
|
||||
// note is explicit that a re-implementation may NOT rely on a listfile being sorted, so this is
|
||||
// our deterministic choice for a set we enumerate ourselves, not a claim about the format.
|
||||
let mut found = chain_addons();
|
||||
// PASS 2 — the loose folders, minus anything the archive already registered.
|
||||
let seen: HashSet<String> = found.iter().map(|a| a.name.to_ascii_lowercase()).collect();
|
||||
found.extend(
|
||||
discover_folder()
|
||||
.into_iter()
|
||||
.filter(|a| !seen.contains(&a.name.to_ascii_lowercase())),
|
||||
);
|
||||
found
|
||||
}
|
||||
|
||||
/// The player's own addons — every folder under the AddOns root with a `<Name>.toc`.
|
||||
/// **The reference's directory-listing order, as a sort key** — the one thing 2166 §7 left open.
|
||||
///
|
||||
/// The reference's loose-folder pass is `0x42ad10`'s kernel32 `FindFirstFileW`/`FindNextFileW`
|
||||
/// walk (wow-re `system/ui/scratch/addon-registry-scan-and-order.md`), and that walk **imposes no
|
||||
/// ordering of its own**: the client never sorts, so its walk order is whatever the host
|
||||
/// filesystem hands back. wow-re states the consequence outright — *"a re-implementation cannot be
|
||||
/// order-identical to the reference, because the reference's order is not a property of the
|
||||
/// reference."*
|
||||
///
|
||||
/// So there is no order to copy, and exactly one order worth choosing: **NTFS's**. It is the order
|
||||
/// every 1.12 install actually walked in, because that is the filesystem the client shipped on, and
|
||||
/// it is therefore the order the whole vanilla addon corpus was written and tested against. A
|
||||
/// directory's `$I30` index is a B-tree sorted by collation rule `COLLATION_FILE_NAME`, which
|
||||
/// compares UTF-16 code units after mapping each through the volume's `$UpCase` table, and breaks a
|
||||
/// case-insensitive tie with a case-sensitive compare of the raw units. Documented in ntfs-3g's
|
||||
/// `layout.h`/`unistr.c` (`ntfs_names_full_collate`); Microsoft's `FindNextFileW` remarks say the
|
||||
/// order is not *guaranteed* but that NTFS returns names "usually in alphabetical order", and
|
||||
/// Raymond Chen's *"Why do NTFS and Explorer disagree on filename sorting?"* names it an ordinal
|
||||
/// compare over the format-time case table rather than a locale collation.
|
||||
///
|
||||
/// **This is the whole difference from the `names.sort()` it replaces, and it is not cosmetic.**
|
||||
/// Byte order sorts every uppercase name before every lowercase one and puts `_` between them;
|
||||
/// NTFS folds case away and puts `_` after `Z`. On the 219-addon vanilla corpus **153 of the 219
|
||||
/// positions move** — `Fubar_*` interleaves with `FuBar_*` instead of following it wholesale,
|
||||
/// `oRA2` climbs 31 places, `zBar` overtakes `Zorlen`, and `_LazyPig`/`_Nameplates` fall from the
|
||||
/// middle to dead last. Since the client runs every addon in one Lua state and adds no versioning
|
||||
/// (wow-re §9: *which provider of a shared library wins is exactly "whichever addon's files run
|
||||
/// last"*), that reshuffle decides which copy of `Tablet-2.0` a corpus of seventy providers ends up
|
||||
/// with.
|
||||
///
|
||||
/// The corpus corroborates the mechanism from the other side: the `!`-prefix convention
|
||||
/// (`!OmniCC`) only ever bought an addon an early slot because the walk was **name-collated**
|
||||
/// rather than creation-ordered — which is what NTFS does and FAT/exFAT do not.
|
||||
///
|
||||
/// **We sort rather than take `read_dir`'s word even on Windows**, deliberately. Raw host order
|
||||
/// would be byte-identical to a reference install on NTFS and arbitrary everywhere else, and it
|
||||
/// would cost three things we need: a capture baseline that cannot depend on the host
|
||||
/// (`crate::local_state`, 0008), [`installed`]'s contract that back-to-back reads answer the same
|
||||
/// rows in the same order, and a corpus harness whose `--diff` means anything (2166 §4 already
|
||||
/// fights Lua-side nondeterminism; filesystem-side would end it). One fixed order, and it is the
|
||||
/// one the corpus grew up under.
|
||||
///
|
||||
/// **Where this stops.** `$UpCase` is captured when a volume is formatted, so two Windows volumes
|
||||
/// can disagree about non-ASCII names and the reference has no single answer there either. For
|
||||
/// ASCII — every folder name in the corpus — `$UpCase` is the identity outside `a`–`z`, which is
|
||||
/// exactly what [`upcase_unit`] reproduces.
|
||||
pub(crate) fn sort_by_directory_order(names: &mut [String]) {
|
||||
names.sort_by_cached_key(|n| collation_key(n));
|
||||
}
|
||||
|
||||
/// One name's `COLLATION_FILE_NAME` key: the upcased UTF-16 units, then the raw ones as the
|
||||
/// tiebreak the rule specifies for names equal but for case.
|
||||
fn collation_key(name: &str) -> (Vec<u16>, Vec<u16>) {
|
||||
let upper: String = name.chars().map(upcase_unit).collect();
|
||||
(
|
||||
upper.encode_utf16().collect(),
|
||||
name.encode_utf16().collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `$UpCase`'s mapping for one character: **1:1 or nothing**.
|
||||
///
|
||||
/// The table is 65536 UTF-16 entries wide, so it can only express a mapping that keeps a name the
|
||||
/// same length. `char::to_uppercase` is Rust's *full* Unicode uppercase and sometimes yields more
|
||||
/// than one char (`ß` → `SS`); `$UpCase` cannot, and leaves such a character alone. Taking the
|
||||
/// mapping only when it is a single char is that constraint, not an approximation of it.
|
||||
fn upcase_unit(c: char) -> char {
|
||||
let mut upper = c.to_uppercase();
|
||||
match (upper.next(), upper.next()) {
|
||||
(Some(u), None) => u,
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's own addons — every folder under the AddOns root with a `<Name>.toc`, in
|
||||
/// [`sort_by_directory_order`].
|
||||
fn discover_folder() -> Vec<Addon> {
|
||||
let Some(root) = root() else {
|
||||
return Vec::new();
|
||||
@@ -527,7 +626,7 @@ fn discover_folder() -> Vec<Addon> {
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.collect();
|
||||
names.sort();
|
||||
sort_by_directory_order(&mut names);
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
@@ -603,6 +702,10 @@ pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnI
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
// Visible to `GetAddOnInfo(index)` until `SMSG_ADDON_INFO` says otherwise (2175). The
|
||||
// manifest cannot answer this: the exclusion is the SERVER's verdict on a `## Secure:`
|
||||
// record, not a directive.
|
||||
hidden: false,
|
||||
enabled: true, // an addon nobody has disabled is enabled; the file below overrides
|
||||
saved_enabled: true, // re-stamped from `enabled` at registration — see `register_addons`
|
||||
loaded: false,
|
||||
@@ -694,16 +797,28 @@ impl InstalledAddOn {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every installed addon, in load order, with `character`'s enable state applied.
|
||||
/// Every installed addon **in the glue list's own order** — `## Title`-sorted, case-insensitively,
|
||||
/// with the folder name as the fallback — and `character`'s enable state applied.
|
||||
///
|
||||
/// The AddOns screens' whole data source. `identity` is `(realm, character)`; `None` reads the
|
||||
/// folder with nobody's enable file, which is the "no character picked yet" case and shows
|
||||
/// everything as enabled.
|
||||
///
|
||||
/// **The order is the reference's, and it is not the folder's** (decision 2175). The glue's
|
||||
/// `AddonList_Update` walks `GetAddOnInfo(i)` for `i = 1..GetNumAddOns()`, and glue `0x46d460`
|
||||
/// resolves that index through the same `0x51df00` the in-game binding does — the flat array
|
||||
/// `SMSG_ADDON_INFO` builds, sorted by comparator `0x51deb0` on `AddOn_GetTitle 0x51df20` with
|
||||
/// `SStrCmpI`. So the rows a player sees are in title order, not folder order, and this screen
|
||||
/// showed folder order because it reads the folder.
|
||||
///
|
||||
/// The array's *membership* needs nothing here: it excludes the addons the server hid, which on a
|
||||
/// stock install is Blizzard's twelve, and this walk is the player's own folder only — they were
|
||||
/// never in it.
|
||||
pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAddOn> {
|
||||
let disabled = disabled_set(identity);
|
||||
// The player's own folder only: the glue's list is what a player can toggle, and the
|
||||
// reference's Blizzard addons are not in it.
|
||||
discover_folder()
|
||||
let mut rows: Vec<InstalledAddOn> = discover_folder()
|
||||
.into_iter()
|
||||
.map(|addon| InstalledAddOn {
|
||||
enabled: !disabled.contains(&addon.name.to_ascii_lowercase()),
|
||||
@@ -720,7 +835,12 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAdd
|
||||
load_on_demand: addon.toc.load_on_demand(),
|
||||
name: addon.name,
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
// `SStrCmpI` folds `'A'..'Z'` by `+0x20` on both operands (`0x64a4c0` → `_strnicmp 0x414310`)
|
||||
// — ASCII only, which is what `to_ascii_lowercase` is. Stable, where the reference's `qsort`
|
||||
// is not: equal keys are undefined there, so any order is conformant.
|
||||
rows.sort_by_key(|a| a.display_title().to_ascii_lowercase());
|
||||
rows
|
||||
}
|
||||
|
||||
/// The lowercased names this character has turned off.
|
||||
@@ -1084,6 +1204,206 @@ impl Walk {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// **The walk order is NTFS's, not byte order** — the fact 2166 §7 left open, pinned here.
|
||||
///
|
||||
/// The reference's loose pass is `FindFirstFileW` and sorts nothing (wow-re
|
||||
/// `addon-registry-scan-and-order.md`), so its order is the host filesystem's; NTFS's `$I30`
|
||||
/// collation is the one we adopt, because it is the order the entire vanilla corpus was
|
||||
/// written against. Two rules distinguish it from `str`'s own `Ord`, and this asserts both:
|
||||
/// **case folds away** (so a lowercase-initial name sorts among its letter's block rather
|
||||
/// than after every uppercase name) and **`_` sorts after `Z`** (0x5F > 0x5A) rather than
|
||||
/// between the cases.
|
||||
///
|
||||
/// The names are the corpus's own, and each pair is one that actually moved when this
|
||||
/// replaced `names.sort()`.
|
||||
#[test]
|
||||
fn the_walk_orders_names_the_way_ntfs_lists_a_directory() {
|
||||
let mut names: Vec<String> = [
|
||||
"_LazyPig",
|
||||
"Zorlen",
|
||||
"zBar",
|
||||
"oRA2",
|
||||
"FuBar_TinyTipFu",
|
||||
"Fubar_EmoteFu",
|
||||
"!OmniCC",
|
||||
"Ace2",
|
||||
"AceGUI",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect();
|
||||
sort_by_directory_order(&mut names);
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
// `!` (0x21) is ahead of everything — which is why the corpus's `!`-prefix
|
||||
// convention ever worked, and evidence the real walk is name-collated.
|
||||
"!OmniCC",
|
||||
// `Ace2` before `AceGUI`: the digit `2` (0x32) beats `G` (0x47), case-blind.
|
||||
"Ace2",
|
||||
"AceGUI",
|
||||
// Case folded: `Fubar_EmoteFu` lands next to its `FuBar_*` siblings instead of
|
||||
// after every one of them. `E` < `T` decides it, not `u` vs `B`.
|
||||
"Fubar_EmoteFu",
|
||||
"FuBar_TinyTipFu",
|
||||
// Lowercase initials sort in their own letter's block, not after `Z`.
|
||||
"oRA2",
|
||||
"zBar",
|
||||
"Zorlen",
|
||||
// `_` (0x5F) sorts AFTER every letter, so this is last rather than mid-list.
|
||||
"_LazyPig",
|
||||
]
|
||||
);
|
||||
|
||||
// Byte order — what we did before — disagrees on all three rules. Asserted so the test
|
||||
// fails if someone "simplifies" the key back to `str`'s `Ord`.
|
||||
let mut plain = names.clone();
|
||||
plain.sort();
|
||||
assert_ne!(plain, names, "NTFS collation is not byte order");
|
||||
}
|
||||
|
||||
/// The tie `COLLATION_FILE_NAME` specifies: names equal but for case fall back to a
|
||||
/// **case-sensitive** compare of the raw units, so uppercase (`A` 0x41) precedes lowercase
|
||||
/// (`a` 0x61). NTFS itself cannot hold both — it is case-insensitive — but a case-sensitive
|
||||
/// host filesystem can, and without the tiebreak the sort would be stable-on-`read_dir` and
|
||||
/// therefore not deterministic at all, which is the property [`installed`] depends on.
|
||||
#[test]
|
||||
fn names_equal_but_for_case_break_the_tie_uppercase_first() {
|
||||
let mut names: Vec<String> = ["fubar", "FuBar", "FUBAR"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect();
|
||||
sort_by_directory_order(&mut names);
|
||||
assert_eq!(names, vec!["FUBAR", "FuBar", "fubar"]);
|
||||
}
|
||||
|
||||
/// `$UpCase` is 65536 UTF-16 entries wide, so it can only express a length-preserving
|
||||
/// mapping: `ß` has no single-unit uppercase and the table leaves it alone, where Rust's full
|
||||
/// `to_uppercase` would expand it to `SS` and sort it under `S`.
|
||||
#[test]
|
||||
fn the_upcase_fold_is_one_to_one_like_the_table_it_models() {
|
||||
assert_eq!(upcase_unit('a'), 'A');
|
||||
assert_eq!(upcase_unit('_'), '_');
|
||||
assert_eq!(upcase_unit('ß'), 'ß');
|
||||
assert_eq!(upcase_unit('é'), 'É');
|
||||
}
|
||||
|
||||
/// **The AddOns screen's rows are in the glue list's order — `## Title`, case-insensitively,
|
||||
/// with the folder name as the fallback** (decision 2175).
|
||||
///
|
||||
/// The reference's `AddonList_Update` walks `GetAddOnInfo(i)` for `i = 1..GetNumAddOns()`, and
|
||||
/// glue `0x46d460` resolves that index through the same `0x51df00` array the in-game binding
|
||||
/// does — `## Title`-sorted by comparator `0x51deb0`. This screen reads the folder instead
|
||||
/// (1197, so the list works before any VM has addons in it), which is why it showed folder
|
||||
/// order until now.
|
||||
///
|
||||
/// The three folder names and the three titles are deliberately *different permutations*, and
|
||||
/// `Middle` is the case control: a byte-wise sort would answer `Middle, aardvark, zebra`.
|
||||
#[test]
|
||||
fn the_addons_screen_lists_in_title_order_not_folder_order() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let (home, _h) = hermetic_root("listorder");
|
||||
write_addon(
|
||||
&home,
|
||||
"Alpha",
|
||||
"## Interface: 11200\n## Title: zebra\n",
|
||||
&[],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"Mike",
|
||||
"## Interface: 11200\n## Title: Middle\n",
|
||||
&[],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"Zulu",
|
||||
"## Interface: 11200\n## Title: aardvark\n",
|
||||
&[],
|
||||
);
|
||||
// No `## Title` at all — sorts under its folder name, the comparator's own fallback.
|
||||
write_addon(&home, "bravo", "## Interface: 11200\n", &[]);
|
||||
|
||||
let rows = installed(None);
|
||||
assert_eq!(
|
||||
rows.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Zulu", "bravo", "Mike", "Alpha"],
|
||||
"titles: {:?}",
|
||||
rows.iter()
|
||||
.map(InstalledAddOn::display_title)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// **The archive pass registers before the loose one, and wins a duplicate** (decision 2175).
|
||||
///
|
||||
/// `AddOn_ScanAddOnDir 0x51c760` runs `0x401470` over each mounted archive's `(listfile)` at
|
||||
/// `0x51c777` and only then `0x42ad10`'s `FindFirstFileW` walk at `0x51c78f`; both funnel into
|
||||
/// `0x51c9b0`, whose name-hash probe **returns immediately on a hit** (`0x51ca10`), and the
|
||||
/// list is tail-inserted with no comparison of any kind (`0x521ad0` mode 2). So registry order
|
||||
/// is registration order, and a name in both sources is registered once, by the archive.
|
||||
///
|
||||
/// We had it the other way round. The duplicate is the half with teeth: before this, a player
|
||||
/// folder named `Blizzard_TalentUI` produced TWO records with one name, and every name lookup
|
||||
/// found only the first.
|
||||
#[test]
|
||||
fn the_archive_pass_registers_first_and_wins_a_duplicate() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let (home, _h) = hermetic_root("scanorder");
|
||||
let chain = chain_addons();
|
||||
if chain.is_empty() {
|
||||
eprintln!("skipping: the chain carries no Blizzard addon rows");
|
||||
return;
|
||||
}
|
||||
// A loose folder that COLLIDES with an archive name, and one that does not.
|
||||
let collide = chain[0].name.clone();
|
||||
write_addon(
|
||||
&home,
|
||||
&collide,
|
||||
"## Interface: 11200\n## Title: Impostor\n",
|
||||
&[],
|
||||
);
|
||||
write_addon(&home, "Loose", "## Interface: 11200\n", &[]);
|
||||
|
||||
let found = discover();
|
||||
let names: Vec<&str> = found.iter().map(|a| a.name.as_str()).collect();
|
||||
|
||||
// Every archive row precedes every loose one.
|
||||
let first_loose = names.iter().position(|n| *n == "Loose").expect("Loose");
|
||||
assert_eq!(
|
||||
first_loose,
|
||||
chain.len(),
|
||||
"the loose folder follows all {} archive rows: {names:?}",
|
||||
chain.len()
|
||||
);
|
||||
|
||||
// The collision is registered ONCE, and by the archive — the loose `## Title: Impostor`
|
||||
// never reaches the registry.
|
||||
assert_eq!(
|
||||
names.iter().filter(|n| **n == collide).count(),
|
||||
1,
|
||||
"{collide} is registered once: {names:?}"
|
||||
);
|
||||
let row = found.iter().find(|a| a.name == collide).unwrap();
|
||||
assert!(
|
||||
matches!(row.source, Source::Chain),
|
||||
"the archive's copy won"
|
||||
);
|
||||
assert_ne!(
|
||||
row.toc.directive("Title"),
|
||||
Some("Impostor"),
|
||||
"the loose manifest did not overwrite the archive's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The chain's Blizzard LoadOnDemand addons are registry rows (1957): every row is
|
||||
/// LoadOnDemand and read off the chain, the eight windows the interface opens through them
|
||||
/// are among the rows, and the manifest lists no addon at all — the reference's own
|
||||
@@ -1914,12 +2234,12 @@ mod tests {
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
|
||||
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
|
||||
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
|
||||
// has a chain (1957) — the registry is one list, as the reference's is.
|
||||
assert_eq!(
|
||||
script.eval::<i64>("return GetNumAddOns()").ok(),
|
||||
Some(1 + chain_addons().len() as i64)
|
||||
);
|
||||
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
|
||||
// Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers
|
||||
// `status = 2` for every secure addon and the array rebuild drops them, which is why the
|
||||
// reference's own AddOns list shows the player's addons and none of Blizzard's.
|
||||
seat_stock_addon_reply(&mut script);
|
||||
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
|
||||
assert_eq!(
|
||||
script
|
||||
.eval::<String>("return GetAddOnMetadata('Umlaut', 'Title')")
|
||||
@@ -1977,6 +2297,22 @@ mod tests {
|
||||
let _ = home;
|
||||
}
|
||||
|
||||
/// What [`crate::ui_script::lifecycle::load_ingame_ui_on_world_entry`] seats before the load
|
||||
/// (decision 2175): the `SMSG_ADDON_INFO` reply, hiding the secure addons the way a real
|
||||
/// server's does.
|
||||
///
|
||||
/// A test that registers a registry and then asks an *index* question needs this, because the
|
||||
/// Lua index space does not exist until the server answers — the reference's own behaviour,
|
||||
/// not a fixture convenience. The hidden set is the same list production pairs the reply
|
||||
/// against, so a test and a live login agree by construction.
|
||||
fn seat_stock_addon_reply(script: &mut UiScript) {
|
||||
let hidden: Vec<String> = benilla_protocol::messages::STOCK_SECURE_ADDONS
|
||||
.iter()
|
||||
.map(|a| a.name.to_string())
|
||||
.collect();
|
||||
script.note_addon_info_reply(&hidden);
|
||||
}
|
||||
|
||||
/// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's
|
||||
/// first acceptance test, and the reason the registry carries directives rather than a name.
|
||||
///
|
||||
@@ -2017,12 +2353,12 @@ mod tests {
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
|
||||
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
|
||||
// has a chain (1957) — the registry is one list, as the reference's is.
|
||||
assert_eq!(
|
||||
script.eval::<i64>("return GetNumAddOns()").ok(),
|
||||
Some(1 + chain_addons().len() as i64)
|
||||
);
|
||||
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
|
||||
// Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers
|
||||
// `status = 2` for every secure addon and the array rebuild drops them, which is why the
|
||||
// reference's own AddOns list shows the player's addons and none of Blizzard's.
|
||||
seat_stock_addon_reply(&mut script);
|
||||
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
|
||||
assert_eq!(
|
||||
script
|
||||
.eval::<Vec<String>>(
|
||||
@@ -2178,12 +2514,12 @@ mod tests {
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
|
||||
// Discovered and described, but NOT run.
|
||||
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
|
||||
// has a chain (1957) — the registry is one list, as the reference's is.
|
||||
assert_eq!(
|
||||
script.eval::<i64>("return GetNumAddOns()").ok(),
|
||||
Some(1 + chain_addons().len() as i64)
|
||||
);
|
||||
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
|
||||
// Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers
|
||||
// `status = 2` for every secure addon and the array rebuild drops them, which is why the
|
||||
// reference's own AddOns list shows the player's addons and none of Blizzard's.
|
||||
seat_stock_addon_reply(&mut script);
|
||||
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
|
||||
assert_eq!(
|
||||
script
|
||||
.eval::<bool>("return IsAddOnLoadOnDemand(1) == 1")
|
||||
@@ -2433,12 +2769,15 @@ mod tests {
|
||||
save_enable_state(&script, Some(&id));
|
||||
|
||||
let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap();
|
||||
// The folder's two in registry order, then the chain's Blizzard rows (1957) — every
|
||||
// registry row has a line, the reference's own one-line-per-addon format.
|
||||
let mut expected = String::from("Drop: disabled\nKeep: enabled\n");
|
||||
// **The chain's Blizzard rows first, then the folder's two** — registry order, which since
|
||||
// decision 2175 is the reference's own: the archive pass registers before the loose one
|
||||
// (`0x51c777` then `0x51c78f`), and the list is tail-inserted with no sort. Every registry
|
||||
// row still gets a line; the format is the reference's one-line-per-addon.
|
||||
let mut expected = String::new();
|
||||
for a in chain_addons() {
|
||||
expected.push_str(&format!("{}: enabled\n", a.name));
|
||||
}
|
||||
expected.push_str("Drop: disabled\nKeep: enabled\n");
|
||||
assert_eq!(
|
||||
written, expected,
|
||||
"the reference's own one-line-per-addon format"
|
||||
|
||||
@@ -83,7 +83,7 @@ fn a_flash_alternates_then_stops() {
|
||||
// Parked until something asks — the whole point of the driver over UIParent's own OnUpdate.
|
||||
s.run(
|
||||
r#"Probe = CreateFrame("Frame", "BenillaFlashProbe", UIParent)
|
||||
Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT")
|
||||
Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT", 0, 0)
|
||||
UIFrameFlash(Probe, 0.05, 0.05, 0.3, nil, 0, 0)"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -363,6 +363,20 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
let identity = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(crate::ui_macro::identity);
|
||||
// **The Lua index space, before a single addon file runs** (decision 2175). The reference has
|
||||
// the array in hand well before `UI_Init 0x48fbf0` reaches the addon walk — `SMSG_ADDON_INFO`
|
||||
// lands during the handshake — so an addon reading `GetNumAddOns()` at file scope sees a
|
||||
// populated one. Seated here rather than off a message for exactly that reason: a feed running
|
||||
// a frame later would seat it after every addon had already asked.
|
||||
//
|
||||
// A server that never answered leaves this `None`, and the array then stays empty for the
|
||||
// session — which is the reference's behaviour too, not a fallback we chose.
|
||||
if let Some(reply) = world
|
||||
.get_resource::<crate::net::AddonInfoReply>()
|
||||
.and_then(|r| r.0.clone())
|
||||
{
|
||||
script.note_addon_info_reply(&reply);
|
||||
}
|
||||
// **The CVar table goes in BEFORE the UI loads**, for the same reason and with a shipped-file
|
||||
// consumer rather than an addon one (decision 2115): the reference's own `UIOptionsFrame.xml`
|
||||
// — hidden, on the manifest for the addons that name it — reads `cameraSmoothStyle` and
|
||||
|
||||
@@ -248,10 +248,10 @@ fn the_templated_close_button_hides_the_frame_it_sits_on() {
|
||||
s.run(
|
||||
r#"MyPanel = CreateFrame("Frame", "MyPanel", UIParent)
|
||||
MyPanel:SetWidth(200) MyPanel:SetHeight(100)
|
||||
MyPanel:SetPoint("CENTER")
|
||||
MyPanel:SetPoint("CENTER", 0, 0)
|
||||
MyPanel:Show()
|
||||
MyClose = CreateFrame("Button", "MyClose", MyPanel, "UIPanelCloseButton")
|
||||
MyClose:SetPoint("TOPRIGHT")"#,
|
||||
MyClose:SetPoint("TOPRIGHT", 0, 0)"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -664,7 +664,9 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
|
||||
/// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes
|
||||
/// through `GameTooltip_SetDefaultAnchor` (stock `ActionButton_SetTooltip`, and the stance/pet/bonus bars the
|
||||
/// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a
|
||||
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors.
|
||||
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors
|
||||
/// (`0x52fe90`'s mode-7 leg reaches `0x52fec2 call 0x767ed0` like every mode but PRESERVE — 2176
|
||||
/// re-confirmed that at the bytes after 2142 predicted the opposite).
|
||||
///
|
||||
/// That drop took the conservative touch, so it re-derived the whole graph on every button the
|
||||
/// cursor crossed — and on nothing else, which is why it survived two rounds of fixing and a
|
||||
@@ -721,9 +723,10 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
|
||||
let derives = s.layout_derivations() - derives_before;
|
||||
assert_eq!(
|
||||
derives, 0,
|
||||
"24 action-bar hovers derived the layout graph {derives} times. `SetOwner`'s ANCHOR_NONE \
|
||||
arm drops the tooltip's anchors, which is a retarget to the EMPTY target set and names \
|
||||
its node like any other (decision 1630, extending 1625)."
|
||||
"24 action-bar hovers derived the layout graph {derives} times. The `ClearAllPoints()` on \
|
||||
`GameTooltip_SetDefaultAnchor`'s next line is a retarget to the EMPTY target set and must \
|
||||
name its node like any other (decision 1630, extending 1625; 2176 moved the emptying \
|
||||
itself off `SetOwner`'s ANCHOR_NONE arm, which the reference leaves alone)."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +272,13 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
|
||||
local named = {}
|
||||
for name, row in pairs(UIPARENT_MANAGED_FRAME_POSITIONS) do
|
||||
if not row.isVar then table.insert(named, name) end
|
||||
-- …and every row's ANCHOR TARGET (`anchorTo`, l.1668), which is a different set: the
|
||||
-- keys are the frames being MOVED, the targets are what they move relative to, and
|
||||
-- several targets (`ActionButton1`, `MainMenuBarArtFrame`) are declared in files a
|
||||
-- one-window kit never loads. An unresolvable name is a RAISE now (decision 2176), so
|
||||
-- a target the kit is missing aborts `UIParent_ManageFramePositions` mid-pass where it
|
||||
-- used to anchor to the parent and carry on.
|
||||
if row.anchorTo then table.insert(named, row.anchorTo) end
|
||||
end
|
||||
benilla_seat(named)
|
||||
for _, name in ipairs({ "SlidingActionBarTexture0", "SlidingActionBarTexture1" }) do
|
||||
|
||||
@@ -150,8 +150,14 @@ fn a_letter_reads_with_the_creator_tail() {
|
||||
/// The scrollbar track (the ref's black `$parentMiddle` strip) belongs in the scrollbar column,
|
||||
/// right of the page — regression for the black bar over the parchment: the ref declares the
|
||||
/// ARTWORK layer before BACKGROUND because `Middle` anchors to `Top` by name and anchors resolve
|
||||
/// at SetPoint time; a reordered transcription silently fell back to the parent. The warning
|
||||
/// check pins the tripwire that now catches any such unresolved named anchor at load.
|
||||
/// at SetPoint time; a reordered transcription silently fell back to the parent.
|
||||
///
|
||||
/// **The tripwire beside it is the whole stock chain's, and it moved with decision 2176.** An
|
||||
/// unresolvable `relativeTo` used to anchor to the parent and warn; a *Lua* one now raises
|
||||
/// (`0x87ccd4`) and an *XML* one is reported and skipped the way `0x767800` reports and skips it
|
||||
/// (`"Couldn't find relative frame: %s"`, `0x878440`). So the assertion is on the new spelling —
|
||||
/// zero of those over every file the player's own chain loads — and the raise's own tripwire is
|
||||
/// `take_errors()` at the end, which a raise inside any `OnLoad` would land in.
|
||||
#[test]
|
||||
fn the_scrollbar_track_sits_right_of_the_page() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -160,7 +166,7 @@ fn the_scrollbar_track_sits_right_of_the_page() {
|
||||
let unresolved: Vec<String> = s
|
||||
.warnings()
|
||||
.into_iter()
|
||||
.filter(|w| w.contains("does not resolve"))
|
||||
.filter(|w| w.contains("Couldn't find relative frame"))
|
||||
.collect();
|
||||
assert!(unresolved.is_empty(), "unresolved anchors: {unresolved:#?}");
|
||||
|
||||
|
||||
@@ -218,6 +218,12 @@ pub enum SessionEvent {
|
||||
/// The tutorial bank, when `SMSG_TUTORIAL_FLAGS` landed during the login handshake
|
||||
/// (decision 1976); `None` when it will arrive in the world stream instead.
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
/// The addons `SMSG_ADDON_INFO` hid from the Lua index space, or **`None` when the server
|
||||
/// never answered our addon block** (decision 2175) — and the two are different: an empty
|
||||
/// list is a reply that hid nothing, `None` is the state in which the reference's
|
||||
/// `GetNumAddOns()` answers 0. It rides the connect event for the same reason the billing
|
||||
/// minutes do: the reply lands during the handshake and nothing downstream can ask again.
|
||||
addon_info: Option<Vec<String>>,
|
||||
},
|
||||
/// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — we are not
|
||||
/// in the world and never were, whatever [`Self::Connected`] said a moment ago.
|
||||
|
||||
@@ -59,6 +59,26 @@
|
||||
/// are byte-identical to the `modulus`/`tdata` array carried in vmangos and cmangos alike.
|
||||
pub const STANDARD_MODULUS_CRC: u32 = 0x4C1C_776D;
|
||||
|
||||
/// **Which addons `SMSG_ADDON_INFO` hid** — the reply's `status` bytes paired back against the
|
||||
/// records we sent (decision 2175).
|
||||
///
|
||||
/// The reply carries no names: the client re-walks its own `## Secure:` list in the same order it
|
||||
/// sent it, so record *i* is `sent[i]`. A `status` of **2** is what makes the reference set
|
||||
/// `[rec+0x29] = 1` (`0x51db84`) and drop the addon from the Lua index space — on a stock install
|
||||
/// that is all twelve, which is why the AddOns list shows only the player's own addons.
|
||||
///
|
||||
/// A reply with **more** records than we sent is truncated to what we sent, and one with fewer
|
||||
/// simply says nothing about the rest: both are the server disagreeing with us about the block,
|
||||
/// and neither is worth failing a login over.
|
||||
pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec<String> {
|
||||
statuses
|
||||
.iter()
|
||||
.zip(sent)
|
||||
.filter(|(status, _)| **status == 2)
|
||||
.map(|(_, addon)| addon.name.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One record in the addon block — a secure addon as the client describes it to the server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SecureAddon<'a> {
|
||||
|
||||
@@ -74,7 +74,7 @@ pub use action_bar::{
|
||||
set_action_button, set_actionbar_toggles, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO,
|
||||
ACTION_KIND_SPELL,
|
||||
};
|
||||
pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
|
||||
pub use addons::{hidden_from_reply, SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
|
||||
pub use area_trigger::area_trigger;
|
||||
pub use attack::{attack_swing, AttackSwingError, AttackerState};
|
||||
pub use auction::{
|
||||
|
||||
@@ -90,6 +90,9 @@ pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE;
|
||||
/// in the world. benilla does not implement Warden; [`crate::WorldSession::connect`] refuses such
|
||||
/// a server at the handshake rather than entering a 30-second kick/reconnect cycle.
|
||||
pub const SMSG_WARDEN_DATA: u16 = 0x02E6;
|
||||
/// The server's answer to `CMSG_AUTH_SESSION`'s addon block — one record per `## Secure:` addon we
|
||||
/// sent, in the order we sent them, with no count and no names (decision 2175).
|
||||
pub const SMSG_ADDON_INFO: u16 = 0x02EF;
|
||||
pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6;
|
||||
/// A zlib envelope holding a **batch of whole movement packets** (763, VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`). Not an edge case: vmangos moves a session onto this carrier the moment it
|
||||
|
||||
@@ -1589,6 +1589,19 @@ pub enum ServerPacket {
|
||||
id: u32,
|
||||
value: u32,
|
||||
},
|
||||
/// `SMSG_ADDON_INFO` (`0x2ef`) — the server's per-addon verdict on the block we sent in
|
||||
/// `CMSG_AUTH_SESSION` (decision 2175).
|
||||
///
|
||||
/// **It carries no count and no names.** The client re-walks its own `## Secure:` list in the
|
||||
/// same order it sent it and reads one record per addon, so record *i* is
|
||||
/// `STOCK_SECURE_ADDONS[i]` (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6).
|
||||
/// Only the `status` byte matters to us: **2** is what makes the client set `[rec+0x29] = 1`
|
||||
/// and drop the addon from the Lua index space, which is why a stock install's AddOns list
|
||||
/// shows the player's addons and none of Blizzard's.
|
||||
AddonInfo {
|
||||
/// One `status` per record, in the order the records arrived.
|
||||
statuses: Vec<u8>,
|
||||
},
|
||||
Other {
|
||||
opcode: u16,
|
||||
},
|
||||
@@ -1934,6 +1947,7 @@ impl ServerPacket {
|
||||
ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(),
|
||||
ServerPacket::InitWorldStates(_) => "SMSG_INIT_WORLD_STATES".into(),
|
||||
ServerPacket::UpdateWorldState { .. } => "SMSG_UPDATE_WORLD_STATE".into(),
|
||||
ServerPacket::AddonInfo { .. } => "SMSG_ADDON_INFO".into(),
|
||||
ServerPacket::Other { opcode } => format!("opcode {opcode:#06x}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,59 @@ const fn is_movement_relay(o: u16) -> bool {
|
||||
}
|
||||
|
||||
/// Parse a server packet body (already decrypted + sized) by opcode.
|
||||
/// `SMSG_ADDON_INFO`'s record stream — **no count, no names, no trailer** (decision 2175).
|
||||
///
|
||||
/// The grammar is the one `AddOn_ReadAddonInfoReply 0x51da70` reads, per record (wow-re
|
||||
/// `system/net/scratch/cmsg-auth-session-addon-block.md` §6):
|
||||
///
|
||||
/// ```text
|
||||
/// u8 status ; 2 -> [rec+0x29] = 1 (hidden from the Lua index space)
|
||||
/// ; 0 -> [rec+0x24] = 2 (rejected)
|
||||
/// ; else -> verify the .toc/Bindings.xml signature
|
||||
/// u8 infoProvided ; persisted into the .pub and echoed next logon
|
||||
/// if infoProvided:
|
||||
/// u8 keyProvided
|
||||
/// if keyProvided: u8[256] modulus
|
||||
/// u32 revision
|
||||
/// u8 urlProvided
|
||||
/// if urlProvided: u8[256] url
|
||||
/// ```
|
||||
///
|
||||
/// The retail capture is the minimal form — 12 x 8 bytes of `{2, 1, 0, 0u32, 0}`.
|
||||
///
|
||||
/// **A truncated record ends the walk and keeps the records before it**, rather than failing the
|
||||
/// packet. Only a record that parsed whole contributes a status, so `statuses[i]` is always
|
||||
/// record *i*'s and the pairing against what we sent cannot slip. The stream is self-delimiting only if every field is read exactly right, so a grammar
|
||||
/// error here would otherwise turn a working login into an unparseable packet; the statuses we did
|
||||
/// read are still true, and a short read is visible as a count that does not match what we sent.
|
||||
fn read_addon_info(r: &mut &[u8]) -> Vec<u8> {
|
||||
let mut statuses = Vec::new();
|
||||
while !r.is_empty() {
|
||||
let Ok(status) = read_u8(r) else { break };
|
||||
let Ok(info_provided) = read_u8(r) else { break };
|
||||
if info_provided != 0 {
|
||||
let Ok(key_provided) = read_u8(r) else { break };
|
||||
if key_provided != 0 && skip(r, 256).is_none() {
|
||||
break;
|
||||
}
|
||||
if read_u32_le(r).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Ok(url_provided) = read_u8(r) else { break };
|
||||
if url_provided != 0 && skip(r, 256).is_none() {
|
||||
break;
|
||||
}
|
||||
statuses.push(status);
|
||||
}
|
||||
statuses
|
||||
}
|
||||
|
||||
/// Advance `r` by `n` bytes, or `None` when it holds fewer.
|
||||
fn skip(r: &mut &[u8], n: usize) -> Option<()> {
|
||||
(r.len() >= n).then(|| *r = &r[n..])
|
||||
}
|
||||
|
||||
pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
let mut r = body;
|
||||
Ok(match opcode {
|
||||
@@ -1596,6 +1649,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
transport: info.transport,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_ADDON_INFO => ServerPacket::AddonInfo {
|
||||
statuses: read_addon_info(&mut r),
|
||||
},
|
||||
other => ServerPacket::Other { opcode: other },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ pub struct WorldSession {
|
||||
/// `SMSG_TUTORIAL_FLAGS` if it landed during the login handshake rather than in the world
|
||||
/// stream (decision 1976) — handed to the world entry the way the billing minutes are.
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
/// `SMSG_ADDON_INFO`'s per-record `status` bytes, in arrival order — `None` until the server
|
||||
/// answers our addon block, which is the state that keeps the Lua index space empty
|
||||
/// (decision 2175). Read by [`Self::take_addon_info`].
|
||||
addon_info: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl WorldSession {
|
||||
@@ -187,6 +191,7 @@ impl WorldSession {
|
||||
chat_language: messages::LANGUAGE_COMMON,
|
||||
billing_time_rested: 0,
|
||||
tutorial_flags: None,
|
||||
addon_info: None,
|
||||
};
|
||||
|
||||
// 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first
|
||||
@@ -276,7 +281,23 @@ impl WorldSession {
|
||||
|
||||
/// Read + decrypt + parse one server packet.
|
||||
pub fn recv(&mut self) -> Result<ServerPacket> {
|
||||
recv_packet(&mut self.stream, Some(self.crypto.decrypter()))
|
||||
let packet = recv_packet(&mut self.stream, Some(self.crypto.decrypter()))?;
|
||||
// **Captured here rather than in a loop arm**, because it is not one loop's business:
|
||||
// `SMSG_ADDON_INFO` lands somewhere between `CMSG_AUTH_SESSION` and the roster, and which
|
||||
// of the handshake's three read loops sees it is the server's timing, not our contract.
|
||||
// `recv` is the one place all of them go through (decision 2175).
|
||||
if let ServerPacket::AddonInfo { statuses } = &packet {
|
||||
self.addon_info = Some(statuses.clone());
|
||||
}
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
/// The `SMSG_ADDON_INFO` verdict, taken once — `None` when the server never answered our addon
|
||||
/// block, which is a real state and not a failure: vmangos only replies when
|
||||
/// `BuildAddonPacket` accepts the block, and a rejection leaves the session alive and silent
|
||||
/// (`WorldSocket.cpp:447`). The reference behaves the same way — no reply, no Lua index space.
|
||||
pub fn take_addon_info(&mut self) -> Option<Vec<u8>> {
|
||||
self.addon_info.take()
|
||||
}
|
||||
|
||||
/// Send a client packet (encrypted header + plaintext body).
|
||||
|
||||
@@ -723,3 +723,56 @@ fn ping_body_golden_and_pong_roundtrip() {
|
||||
other => panic!("expected one Pong event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SMSG_ADDON_INFO`, against the retail capture's own bytes** (decision 2175).
|
||||
///
|
||||
/// The reply carries no count and no names: the client re-walks the `## Secure:` list it sent and
|
||||
/// reads one record per addon. The 2006 capture is the minimal form — 96 bytes, 12 x
|
||||
/// `{status = 2, infoProvided = 1, keyProvided = 0, revision = 0u32, urlProvided = 0}` — and
|
||||
/// `status = 2` is what sets `[rec+0x29] = 1` (`0x51db84`) and drops the addon from the Lua index
|
||||
/// space (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6).
|
||||
#[test]
|
||||
fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||||
let capture: Vec<u8> = std::iter::repeat_n(hx("0201000000000000"), 12)
|
||||
.flatten()
|
||||
.collect();
|
||||
assert_eq!(capture.len(), 96, "the capture is 12 x 8 bytes");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &capture).unwrap();
|
||||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||||
panic!("expected AddonInfo");
|
||||
};
|
||||
assert_eq!(statuses, vec![2u8; 12]);
|
||||
|
||||
// Paired back against the block we send, every stock addon is hidden from the index space —
|
||||
// which is why the reference's AddOns list shows the player's addons and none of Blizzard's.
|
||||
let hidden = messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS);
|
||||
assert_eq!(hidden.len(), 12);
|
||||
assert!(
|
||||
hidden.iter().all(|n| n.starts_with("Blizzard_")),
|
||||
"{hidden:?}"
|
||||
);
|
||||
|
||||
// A record that says it carries a key and a url is 8 + 256 + 256 bytes, and `status = 1`
|
||||
// leaves the addon VISIBLE — the exclusion is `2` alone, not "the server said something".
|
||||
let mut fat = vec![1u8, 1, 1];
|
||||
fat.extend(std::iter::repeat_n(0xABu8, 256)); // modulus
|
||||
fat.extend([0u8; 4]); // revision
|
||||
fat.push(1); // urlProvided
|
||||
fat.extend(std::iter::repeat_n(b'x', 256)); // url
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &fat).unwrap();
|
||||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||||
panic!("expected AddonInfo");
|
||||
};
|
||||
assert_eq!(statuses, vec![1u8], "the fat record parsed whole");
|
||||
assert!(messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS).is_empty());
|
||||
|
||||
// A truncated record contributes nothing, and the whole ones before it survive — the pairing
|
||||
// against what we sent cannot slip.
|
||||
let mut short = hx("0201000000000000").to_vec();
|
||||
short.extend([2u8, 1]); // a second record that stops mid-way
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &short).unwrap();
|
||||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||||
panic!("expected AddonInfo");
|
||||
};
|
||||
assert_eq!(statuses, vec![2u8], "only the whole record counted");
|
||||
}
|
||||
|
||||
@@ -284,21 +284,15 @@ impl Loader<'_> {
|
||||
x.unwrap_or(0.0),
|
||||
y.unwrap_or(0.0),
|
||||
);
|
||||
// A target that is not built yet waits for the frame's subtree (`Loader::deferred_anchors`).
|
||||
if args
|
||||
.1
|
||||
.as_deref()
|
||||
.is_some_and(|n| !self.anchor_target_exists(n))
|
||||
{
|
||||
self.deferred_anchors.push(super::DeferredAnchor {
|
||||
wrapper: wrapper.clone(),
|
||||
region: false,
|
||||
args,
|
||||
dbg: dbg.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
self.call(wrapper, "SetPoint", args, dbg);
|
||||
let d = super::DeferredAnchor {
|
||||
wrapper: wrapper.clone(),
|
||||
region: false,
|
||||
args,
|
||||
dbg: dbg.to_string(),
|
||||
};
|
||||
// The XML path's own law, which resolves the name itself and defers a target
|
||||
// the enclosing frame's subtree has not built yet (`Loader::apply_anchor`).
|
||||
self.apply_anchor(d, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,10 +975,52 @@ impl Loader<'_> {
|
||||
/// Returns the frame's own name (`None` if it is anonymous) and the name of its nearest
|
||||
/// **named** ancestor — rf27 rule 3's walk, with [`framexml::DEFAULT_PARENT_NAME`] when there
|
||||
/// is none.
|
||||
/// Can a `relativeTo` name resolve right now — a frame in the arena or a named region?
|
||||
pub(super) fn anchor_target_exists(&self, name: &str) -> bool {
|
||||
let model = self.model();
|
||||
model.arena.lookup(name).is_some() || model.region_names.contains_key(name)
|
||||
pub(super) fn apply_anchor(&mut self, d: DeferredAnchor, may_defer: bool) {
|
||||
let rel: Value = match d.args.1.as_deref() {
|
||||
Some(name) => {
|
||||
match crate::script::object::anchor_args::resolve_xml_relative_to(self.lua(), name)
|
||||
{
|
||||
Some(t) => Value::Table(t),
|
||||
// Nothing by that name YET — a target declared later in the enclosing frame's
|
||||
// subtree. Hold it until the subtree is complete (`deferred_anchors`) and try
|
||||
// once more; a miss THEN is a real miss and takes the reference's leg.
|
||||
None if may_defer => {
|
||||
self.deferred_anchors.push(d);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
self.report
|
||||
.warnings
|
||||
.push(format!("{}: Couldn't find relative frame: {name}", d.dbg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `relativeTo=`: `ebx` still holds `[ebp-0x14]`, the layout parent default that
|
||||
// `0x76785b call [edx+0xc]` (= `0x76c6e0`) seeded before the `<Anchor>` loop. A frame
|
||||
// with no parent gets the screen root there, which is what a Lua `nil` means here.
|
||||
None => match d
|
||||
.wrapper
|
||||
.call_method::<Option<Table>>("GetParent", ())
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
Some(t) => Value::Table(t),
|
||||
None => Value::Nil,
|
||||
},
|
||||
};
|
||||
let DeferredAnchor {
|
||||
wrapper,
|
||||
region,
|
||||
args: (point, _, rel_point, x, y),
|
||||
dbg,
|
||||
} = d;
|
||||
let args = (point, rel, rel_point, x, y);
|
||||
if region {
|
||||
self.call_region(&wrapper, "SetPoint", args, &dbg);
|
||||
} else {
|
||||
self.call(&wrapper, "SetPoint", args, &dbg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the anchors deferred since `mark` (the enclosing `decorate`'s entry), in order. A
|
||||
@@ -986,11 +1028,7 @@ impl Loader<'_> {
|
||||
pub(super) fn drain_deferred_anchors(&mut self, mark: usize) {
|
||||
let pending: Vec<DeferredAnchor> = self.deferred_anchors.drain(mark..).collect();
|
||||
for d in pending {
|
||||
if d.region {
|
||||
self.call_region(&d.wrapper, "SetPoint", d.args, &d.dbg);
|
||||
} else {
|
||||
self.call(&d.wrapper, "SetPoint", d.args, &d.dbg);
|
||||
}
|
||||
self.apply_anchor(d, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -283,21 +283,15 @@ impl Loader<'_> {
|
||||
x.unwrap_or(0.0),
|
||||
y.unwrap_or(0.0),
|
||||
);
|
||||
// A target that is not built yet waits for the frame's subtree (`Loader::deferred_anchors`).
|
||||
if args
|
||||
.1
|
||||
.as_deref()
|
||||
.is_some_and(|n| !self.anchor_target_exists(n))
|
||||
{
|
||||
self.deferred_anchors.push(super::DeferredAnchor {
|
||||
wrapper: wrapper.clone(),
|
||||
region: true,
|
||||
args,
|
||||
dbg: dbg.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
self.call_region(wrapper, "SetPoint", args, dbg);
|
||||
let d = super::DeferredAnchor {
|
||||
wrapper: wrapper.clone(),
|
||||
region: true,
|
||||
args,
|
||||
dbg: dbg.to_string(),
|
||||
};
|
||||
// The XML path's own law, which resolves the name itself and defers a target
|
||||
// the enclosing frame's subtree has not built yet (`Loader::apply_anchor`).
|
||||
self.apply_anchor(d, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,17 @@ pub struct AddOnInfo {
|
||||
/// `## Interface` as the client parses it (`Toc::interface_version` — the leading integer,
|
||||
/// `0` when the line is absent). What the version gate compares (decision 1292).
|
||||
pub interface: u32,
|
||||
/// **The server excluded this record from the Lua index space** — `[rec+0x29]`, which
|
||||
/// `AddOn_ReadAddonInfoReply 0x51da70` sets to 1 for every `SMSG_ADDON_INFO` record whose
|
||||
/// `status` byte is **2** (`0x51db84`), and which the array rebuild then drops (`0x51dc4f
|
||||
/// mov al,[ebx+0x29]` / `0x51dc54 jne`). It is written nowhere else but the ctor's zero
|
||||
/// (`0x52059f`), so an addon the reply never covers stays visible.
|
||||
///
|
||||
/// The reply covers exactly the `## Secure:` addons, in the order the client sent them, and
|
||||
/// the 2006 retail capture answers `status = 2` for all twelve — which is why the stock
|
||||
/// AddOns list shows the player's addons and none of Blizzard's (wow-re
|
||||
/// `system/net/scratch/cmsg-auth-session-addon-block.md` §6).
|
||||
pub hidden: bool,
|
||||
/// The addon's files sit in the player's patch chain, not the AddOns folder — Blizzard's own
|
||||
/// LoadOnDemand addons (`Blizzard_TrainerUI` and its eleven siblings), which the reference
|
||||
/// loads through the same `LoadAddOn` path as a player's (1957). Read through the host's
|
||||
@@ -163,11 +174,15 @@ fn addon_key(lua: &Lua, model: &Model, key: &Value, usage: &'static str) -> mlua
|
||||
// `_ftol 0x40a2b0` truncates toward zero (not `floor`), then `dec eax`, then an unsigned
|
||||
// compare — so the whole out-of-range family collapses onto one `u32` bound test.
|
||||
let index0 = (n as i64 as i32).wrapping_sub(1) as u32 as usize;
|
||||
return match index0 < model.addons.len() {
|
||||
true => Ok(AddonKey::Index(index0)),
|
||||
false => Err(mlua::Error::RuntimeError(format!(
|
||||
// **The index space is NOT the registry** (decision 2175). `0x51df00` reads
|
||||
// `[[0xbe1b94] + 4*idx]` — the flat, Title-sorted, hidden-filtered name array — and its
|
||||
// bound is that array's own count `[0xbe1b90]`, which `GetNumAddOns 0x51def0` returns.
|
||||
// The registry list is a different order over a different set.
|
||||
return match model.addon_index.get(index0) {
|
||||
Some(&row) => Ok(AddonKey::Index(row)),
|
||||
None => Err(mlua::Error::RuntimeError(format!(
|
||||
"AddOn index must be in the range of 1 to {}",
|
||||
model.addons.len()
|
||||
model.addon_index.len()
|
||||
))),
|
||||
};
|
||||
}
|
||||
@@ -179,6 +194,44 @@ fn addon_key(lua: &Lua, model: &Model, key: &Value, usage: &'static str) -> mlua
|
||||
}
|
||||
}
|
||||
|
||||
/// **Rebuild the Lua index space** — the tail block `[0x51dc30, 0x51dcdf)` of
|
||||
/// `AddOn_ReadAddonInfoReply 0x51da70`, and the only place that array is ever built
|
||||
/// (decision 2175, wow-re `system/ui/scratch/addon-registry-scan-and-order.md` §7).
|
||||
///
|
||||
/// Three properties, all byte-read, all easy to get wrong:
|
||||
///
|
||||
/// - **The set is filtered.** `0x51dc4f mov al,[ebx+0x29]` / `0x51dc54 jne` drops every record the
|
||||
/// server marked `status = 2`. On a stock install that is all twelve `Blizzard_*` addons, which
|
||||
/// is why the AddOns list shows only the player's own.
|
||||
/// - **The order is by `## Title:`, not by folder name.** Comparator `0x51deb0` resolves both
|
||||
/// sides through `AddOn_GetTitle 0x51df20` and falls back to the folder name only when the
|
||||
/// record declares no `Title` (`0x51ded0`/`0x51ded6`), then compares with `SStrCmpI` — case
|
||||
/// INSENSITIVE. A materially different permutation from the registry's.
|
||||
/// - **No reply, no array.** `[0xbe1b90]` starts at 0 and is written nowhere else, so
|
||||
/// `GetNumAddOns()` answers 0 until the server replies.
|
||||
///
|
||||
/// The reference's `qsort 0x73f727` is **not stable**, so two records with equal keys have no
|
||||
/// defined relative order there; ours is stable, which is a strict refinement of "undefined" and
|
||||
/// cannot disagree with a defined case.
|
||||
fn rebuild_index(model: &mut Model) {
|
||||
let Some(hidden) = model.addon_info_hidden.as_ref() else {
|
||||
model.addon_index = Vec::new(); // no reply yet — the reference's own empty array
|
||||
return;
|
||||
};
|
||||
let hidden: std::collections::HashSet<&str> = hidden.iter().map(String::as_str).collect();
|
||||
for a in &mut model.addons {
|
||||
a.hidden = hidden.contains(a.name.to_ascii_lowercase().as_str());
|
||||
}
|
||||
let mut index: Vec<usize> = (0..model.addons.len())
|
||||
.filter(|&i| !model.addons[i].hidden)
|
||||
.collect();
|
||||
index.sort_by_key(|&i| {
|
||||
let a = &model.addons[i];
|
||||
a.title.as_deref().unwrap_or(&a.name).to_ascii_lowercase()
|
||||
});
|
||||
model.addon_index = index;
|
||||
}
|
||||
|
||||
/// A name → a position, folded. Names compare case-insensitively for the same reason dependency
|
||||
/// lookup does: a `.toc` may spell a name any way.
|
||||
fn by_name(model: &Model, name: &str) -> Option<usize> {
|
||||
@@ -252,7 +305,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"GetNumAddOns",
|
||||
lua.create_function(|lua, ()| {
|
||||
Ok(lua.app_data_ref::<Model>().expect("model").addons.len())
|
||||
// `0x51def0` is six bytes: `return [0xbe1b90]` — the count of the SORTED array, not the
|
||||
// registry size (decision 2175).
|
||||
Ok(lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model")
|
||||
.addon_index
|
||||
.len())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -817,6 +876,26 @@ impl super::UiScript {
|
||||
model.addons_root = root;
|
||||
model.addons_saved_account = saved_account;
|
||||
model.addons_saved_character = saved_character;
|
||||
// The reference builds the registry at glue login and the Lua array only when the server
|
||||
// answers; our registry is built at world entry, by which time the reply has long landed.
|
||||
// So the rebuild runs here, off whatever the reply said — and does nothing at all if no
|
||||
// reply ever came, which is the reference's own pre-reply state.
|
||||
rebuild_index(&mut model);
|
||||
}
|
||||
|
||||
/// **The `SMSG_ADDON_INFO` (`0x2ef`) verdict** — the names the server answered `status = 2`
|
||||
/// for, which the client stores as `[rec+0x29] = 1` and which the Lua index array then drops
|
||||
/// (decision 2175).
|
||||
///
|
||||
/// The reply carries no count and no names: it is one record per `## Secure:` addon, in the
|
||||
/// order the client itself sent them in `CMSG_AUTH_SESSION`, so the caller does the pairing
|
||||
/// and hands us the names (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6).
|
||||
/// Calling this with an empty slice is meaningful and different from never calling it: it
|
||||
/// records that a reply arrived and hid nothing.
|
||||
pub fn note_addon_info_reply(&mut self, hidden: &[String]) {
|
||||
let mut model = self.model_mut();
|
||||
model.addon_info_hidden = Some(hidden.iter().map(|n| n.to_ascii_lowercase()).collect());
|
||||
rebuild_index(&mut model);
|
||||
}
|
||||
|
||||
/// Execute one addon's saved-variables files, at the startup walk's verified position — the
|
||||
|
||||
@@ -64,6 +64,17 @@ pub(crate) struct Model {
|
||||
/// Every discovered addon, in load order — the AddOn API's registry, filled by the host at
|
||||
/// world entry ([`super::UiScript::register_addons`]). See [`super::addon`].
|
||||
pub(crate) addons: Vec<super::addon::AddOnInfo>,
|
||||
/// **The Lua index space** — positions into [`Self::addons`], `## Title`-sorted and
|
||||
/// hidden-filtered (decision 2175). NOT the registry: the reference keeps two structures and
|
||||
/// they are different permutations of different sets (wow-re
|
||||
/// `system/ui/scratch/addon-registry-scan-and-order.md` §7).
|
||||
pub(crate) addon_index: Vec<usize>,
|
||||
/// The lowercased names `SMSG_ADDON_INFO` marked `status = 2`, or **`None` when no reply has
|
||||
/// arrived this session** — and `None` is why [`Self::addon_index`] can be legitimately empty
|
||||
/// (decision 2175). `[0xbe1b90]` is zeroed by the registry reset `0x51fad1` and written
|
||||
/// nowhere but the reply's own rebuild, so `GetNumAddOns()` really is 0 until the server
|
||||
/// answers.
|
||||
pub(crate) addon_info_hidden: Option<Vec<String>>,
|
||||
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
|
||||
pub(crate) addons_root: Option<std::path::PathBuf>,
|
||||
/// The host's reader for a chain-sourced addon's files (`AddOnInfo::chain`), by
|
||||
@@ -1812,6 +1823,8 @@ impl Model {
|
||||
pub(crate) fn new() -> Model {
|
||||
Model {
|
||||
addons: Vec::new(),
|
||||
addon_index: Vec::new(),
|
||||
addon_info_hidden: None,
|
||||
addons_root: None,
|
||||
addons_chain_reader: None,
|
||||
measurer: None,
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::widget::{FrameHandle, FrameKind};
|
||||
|
||||
// The frame method-table clusters — split out purely for size (each module's own doc says what
|
||||
// lives there); this file keeps the shared id/handle plumbing, `install`, and `CreateFrame`.
|
||||
pub(crate) mod anchor_args;
|
||||
mod events_regions;
|
||||
mod frame_state;
|
||||
mod layout_methods;
|
||||
@@ -694,6 +695,25 @@ pub(super) fn create_frame(
|
||||
}
|
||||
}
|
||||
|
||||
// **A leading `$parent` in the NAME is expanded, here as in XML.** `CreateFrame 0x7060b0`
|
||||
// does not store the name itself: it builds a synthetic node, sets `name=` on it
|
||||
// (`0x706225 push 0x838090 "name"` → `0x70622d` SetAttribute) alongside `parent=` and
|
||||
// `inherits=`, and hands it to the XML frame builder `0x6ee280` with the parent object in
|
||||
// `edx` — so the name reaches `CScriptRegion::SetName 0x76c650` by exactly the route an XML
|
||||
// `name=` does, and `0x76c691` is one of the expander's two call sites (wow-re
|
||||
// `name-string-widget-resolution.md` §5/§6).
|
||||
//
|
||||
// The base is the frame's **parent's** first named ancestor — the same walk `$parent` in a
|
||||
// `relativeTo` takes, one link higher than the anchoring frame's own.
|
||||
//
|
||||
// Not academic: `FonzAppraiser` names every widget it builds this way (`"$parentDropdown"..n`,
|
||||
// `"$parentCloseButton"`, ~30 sites), and until decision 2176 made an unresolvable anchor
|
||||
// raise, the unexpanded name only showed up as a warning nobody read.
|
||||
let name = name.map(|n| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
crate::framexml::resolve_name(&n, &parent_token_base(&model, parent_handle))
|
||||
});
|
||||
|
||||
// Create in the arena, mint the id, seed a default layout input. All under one write borrow.
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `SetPoint`/`SetAllPoints`' argument ladder — **one** transcription of `0x7a2540` / `0x7a2940`.
|
||||
//!
|
||||
//! The two bindings are `CScriptRegion` methods registered **once**, at `.data 0x87ca38` and
|
||||
//! `0x87ca40` inside the 19-record table `[0x87c9b8, 0x87ca50)` whose registrar `0x7a2e70` supplies
|
||||
//! base and count — so Frame, Texture, FontString and Model all run the *same* function (wow-re
|
||||
//! `system/ui/ui.md` §(b2), `scratch/name-string-widget-resolution.md` §3-4, ledger rows
|
||||
//! `0x7a2540`/`0x7a2940`). This module is that single function's argument half; what each caller
|
||||
//! keeps for itself is only the thing the reference reads off the receiver — the layout parent's
|
||||
//! default id, and where the anchor is stored.
|
||||
//!
|
||||
//! **Every failure leg RAISES**, which is the point of the module (decision 2176, closing 2105's
|
||||
//! "Named, not done"). `luaL_error` does not return: `luaG_errormsg [0x6fc780,0x6fc861)` and
|
||||
//! `luaD_throw [0x6f5d80,0x6f5da5)` hold zero `ret`s, and `luaD_throw`'s *elided* epilogue is the
|
||||
//! positive control that says so. There is no fallback and no silent no-op on any of them.
|
||||
//!
|
||||
//! The **XML** `<Anchor relativeTo=>` path is a different function and a different law — it
|
||||
//! resolves through the same `0x76c700` but reports `"Couldn't find relative frame: %s"` at
|
||||
//! severity 1 and **skips the anchor** (`0x767969 test ebx,ebx` → `0x76796d` → `jmp 0x7679dd`,
|
||||
//! past the `0x7679c8 call 0x767c70`). That half lives in `crate::loader::geometry`, which
|
||||
//! therefore resolves its own names and never reaches the ladder here.
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::layout::Point;
|
||||
use crate::script::{Model, SCREEN};
|
||||
|
||||
use super::{decode_id, point_from_str, prefetch_named_target, resolve_named_target, NamedTarget};
|
||||
|
||||
/// What the reference prints where a widget has no name — the literal `0x84c7f0`, reached from
|
||||
/// `call [primary_vt+0x4]` (`GetName` → `[widget+0x98]`) returning NULL.
|
||||
pub(crate) const UNNAMED: &str = "<unnamed>";
|
||||
|
||||
/// `relativeTo`, decoded — the four tags `lua_type(L,3)` accepts (`0x7a25e6`–`0x7a2620`); anything
|
||||
/// else, **absent included** (`TNONE` = −1), is a Usage raise on the `SetPoint` side.
|
||||
pub(crate) enum RelTarget {
|
||||
/// A widget wrapper table (`0x7a26c9`: `rawgeti(3,0)` → `touserdata` → `+0x24`).
|
||||
///
|
||||
/// **The reference runs no `IsA` check on this arm** — unlike the `this` ladder and unlike the
|
||||
/// string arm, whose check lives inside `0x76c760` — so `add eax,0x24` is applied to whatever
|
||||
/// `lua_touserdata` returned and a table carrying any light userdata at `[0]` is accepted as a
|
||||
/// widget. That is a wild pointer, not a behaviour: [`resolve_rel_target`] takes the layout
|
||||
/// parent for a table that decodes to no live widget, and says so there.
|
||||
Wrapper(Table),
|
||||
/// A name string, already read out of `_G` with no `Model` guard alive ([`NamedTarget`]).
|
||||
Named(NamedTarget),
|
||||
/// An explicit `nil` — the **screen root** `ds:[0xcf0bd8]` (`0x7a2710`), *not* the parent. One
|
||||
/// stock site takes it (`UIParent.lua:1549`) and fourteen corpus sites do, every one of them
|
||||
/// meaning "the screen" (`pfUI`'s screenshot caption, `FixCTGroups`' cursor-space plate).
|
||||
Screen,
|
||||
/// A NUMBER in the `relativeTo` slot: `edi` stays 3 and the number is `offsetX` — the
|
||||
/// `SetPoint(point, x, y)` shorthand (`0x7a270e`). `relativeTo` keeps the layout parent's
|
||||
/// default (`0x76c6e0`: `[+0x9c]`'s layout sub-object, else the screen root).
|
||||
DefaultParent,
|
||||
}
|
||||
|
||||
/// `SetPoint`'s parsed arguments (`0x7a2540`), ready for the caller's own commit.
|
||||
pub(crate) struct PointArgs {
|
||||
pub(crate) point: Point,
|
||||
pub(crate) target: RelTarget,
|
||||
pub(crate) rel_point: Point,
|
||||
pub(crate) x: f32,
|
||||
pub(crate) y: f32,
|
||||
}
|
||||
|
||||
/// `Usage: %s:SetPoint(…)` — `luaL_error(0x87cc28)`, the string read out of the reference image.
|
||||
fn usage(who: &str) -> mlua::Error {
|
||||
mlua::Error::runtime(format!(
|
||||
"Usage: {who}:SetPoint(\"point\" [, region or nil] [, \"relativePoint\"] \
|
||||
[, offsetX, offsetY])"
|
||||
))
|
||||
}
|
||||
|
||||
/// `%s:SetPoint(): Unknown region point` — `0x87cd04`, raised by the 9-entry name scan `0x6f1840`
|
||||
/// for either the `point` or a `relativePoint` that is a string it rejects.
|
||||
fn unknown_point(who: &str) -> mlua::Error {
|
||||
mlua::Error::runtime(format!("{who}:SetPoint(): Unknown region point"))
|
||||
}
|
||||
|
||||
/// `%s:%s(): Couldn't find region named '%s'` — `0x87ccd4` for `SetPoint`, `0x87cd84` for
|
||||
/// `SetAllPoints`. **No fallback and no no-op**: this is the leg benilla answered with the parent
|
||||
/// plus a warning until 2176.
|
||||
pub(crate) fn no_such_region(who: &str, verb: &str, name: &str) -> mlua::Error {
|
||||
mlua::Error::runtime(format!(
|
||||
"{who}:{verb}(): Couldn't find region named '{name}'"
|
||||
))
|
||||
}
|
||||
|
||||
/// `%s:%s(): trying to anchor to itself` — `0x87cca8` / `0x87cd54`, the `[ebp-4] == this+0x24`
|
||||
/// compare at `0x7a2764` / `0x7a2aa8`.
|
||||
fn anchor_to_self(who: &str, verb: &str) -> mlua::Error {
|
||||
mlua::Error::runtime(format!("{who}:{verb}(): trying to anchor to itself"))
|
||||
}
|
||||
|
||||
/// `lua_isnumber 0x6f34d0` — a number, or a string Lua can read as one.
|
||||
fn as_number(lua: &Lua, v: &Value) -> Option<f32> {
|
||||
lua.coerce_number(v.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|n| n as f32)
|
||||
}
|
||||
|
||||
/// `lua_isstring 0x6f3510` — tag 4 **or** 3, so a number is a string here.
|
||||
fn as_string(lua: &Lua, v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
|
||||
.coerce_string(v.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.to_str().ok().map(|r| r.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` in the reference's own order:
|
||||
/// the two tag gates first (both Usage), then the point-name scan, then `relativePoint`, then the
|
||||
/// offsets. The order is observable — an unknown point with an *absent* `relativeTo` raises Usage,
|
||||
/// not "Unknown region point".
|
||||
///
|
||||
/// `who` names the receiver for the error strings, and `parent_base` is what a leading `$parent`
|
||||
/// expands to. Both are the caller's to compute, under a short `Model` borrow, **before** this
|
||||
/// runs: the `_G` read here is a `lua_gettable` and an `__index` on `_G` can call straight back
|
||||
/// into a binding ([`NamedTarget`]).
|
||||
pub(crate) fn parse_set_point(
|
||||
lua: &Lua,
|
||||
args: &MultiValue,
|
||||
who: &str,
|
||||
parent_base: &str,
|
||||
) -> mlua::Result<PointArgs> {
|
||||
// arg 2 = point. `lua_isstring(L,2)` false → `0x7a290d` Usage (`0x7a25d2`).
|
||||
let point_str = args.front().and_then(|v| as_string(lua, v));
|
||||
let Some(point_str) = point_str else {
|
||||
return Err(usage(who));
|
||||
};
|
||||
|
||||
// arg 3 = relativeTo. `lua_type(L,3)` ∈ {4 STRING, 5 TABLE, 0 NIL, 3 NUMBER}, else Usage —
|
||||
// and an **absent** argument is `TNONE` = −1, which fails all four (`0x7a25e6`–`0x7a2620`).
|
||||
let Some(rel_arg) = args.get(1) else {
|
||||
return Err(usage(who));
|
||||
};
|
||||
let target = match rel_arg {
|
||||
Value::String(s) => RelTarget::Named(match s.to_str() {
|
||||
Ok(n) => prefetch_named_target(lua, n.as_ref(), Some(parent_base)),
|
||||
Err(_) => NamedTarget::unreadable(),
|
||||
}),
|
||||
Value::Table(t) => RelTarget::Wrapper(t.clone()),
|
||||
Value::Nil => RelTarget::Screen,
|
||||
Value::Number(_) | Value::Integer(_) => RelTarget::DefaultParent,
|
||||
_ => return Err(usage(who)),
|
||||
};
|
||||
|
||||
// The point name, scanned by `0x6f1840` only now (`0x7a264a`) — after both tag gates.
|
||||
let point = point_from_str(&point_str).ok_or_else(|| unknown_point(who))?;
|
||||
|
||||
// `relativePoint` is seeded from `point` (`0x7a2697`) and read at index `edi`, which the
|
||||
// number arm above left at 3 and every other arm advanced to 4. **Only a STRING is consumed**
|
||||
// (`0x7a2810 lua_type == 4`, strictly — not `lua_isstring`); anything else leaves the seed and
|
||||
// the offsets are taken from here.
|
||||
let mut cur = if matches!(target, RelTarget::DefaultParent) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let mut rel_point = point;
|
||||
if let Some(Value::String(s)) = args.get(cur) {
|
||||
let name = s.to_str()?;
|
||||
rel_point = point_from_str(name.as_ref()).ok_or_else(|| unknown_point(who))?;
|
||||
cur += 1;
|
||||
}
|
||||
|
||||
// **Both offsets, or neither** (`0x7a2888 je 0x7a28e6`): each is gated on `lua_isnumber`, and
|
||||
// one failing zeroes the pair rather than only itself.
|
||||
let (x, y) = match (
|
||||
args.get(cur).and_then(|v| as_number(lua, v)),
|
||||
args.get(cur + 1).and_then(|v| as_number(lua, v)),
|
||||
) {
|
||||
(Some(x), Some(y)) => (x, y),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
|
||||
Ok(PointArgs {
|
||||
point,
|
||||
target,
|
||||
rel_point,
|
||||
x,
|
||||
y,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `SetAllPoints([relativeTo])` (`0x7a2940`) — **the same resolver, a different argument
|
||||
/// law**, and the three differences are per-binding facts rather than a family rule:
|
||||
///
|
||||
/// 1. An **omitted** argument is legal: it falls through to the layout parent with no raise, where
|
||||
/// `SetPoint` raises Usage.
|
||||
/// 2. The name path is gated on **`lua_isstring`** (`0x7a29ea`), tag 4 **or** 3 — so
|
||||
/// `f:SetAllPoints(42)` stringifies and looks up `_G["42"]`, where `SetPoint` tests
|
||||
/// `lua_type == 4` strictly and routes a number to the offset shorthand.
|
||||
/// 3. There is no `relativePoint` or offset parsing at all.
|
||||
pub(crate) fn parse_set_all_points(lua: &Lua, arg: Option<&Value>, parent_base: &str) -> RelTarget {
|
||||
match arg {
|
||||
Some(Value::Table(t)) => RelTarget::Wrapper(t.clone()),
|
||||
Some(Value::Nil) => RelTarget::Screen,
|
||||
// `lua_isstring` — a NUMBER takes the name path here, unlike `SetPoint`.
|
||||
Some(v) => match as_string(lua, v) {
|
||||
Some(n) => RelTarget::Named(prefetch_named_target(lua, &n, Some(parent_base))),
|
||||
None => RelTarget::DefaultParent,
|
||||
},
|
||||
None => RelTarget::DefaultParent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a parsed [`RelTarget`] into the layout id to anchor against, raising the reference's own
|
||||
/// errors. `me` is the receiver's own id — the self-anchor compare (`0x7a2764` / `0x7a2aa8`).
|
||||
///
|
||||
/// The **wrapper** arm takes `default_parent` where the reference would take a wild pointer (see
|
||||
/// [`RelTarget::Wrapper`]); every other arm is the reference's.
|
||||
pub(crate) fn resolve_rel_target(
|
||||
model: &Model,
|
||||
target: &RelTarget,
|
||||
who: &str,
|
||||
verb: &str,
|
||||
me: u32,
|
||||
default_parent: u32,
|
||||
) -> mlua::Result<u32> {
|
||||
let id = match target {
|
||||
RelTarget::Wrapper(t) => decode_id(t)
|
||||
.ok()
|
||||
.filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id))
|
||||
.unwrap_or(default_parent),
|
||||
RelTarget::Named(nt) => {
|
||||
resolve_named_target(model, nt).ok_or_else(|| no_such_region(who, verb, &nt.name))?
|
||||
}
|
||||
RelTarget::Screen => SCREEN,
|
||||
RelTarget::DefaultParent => default_parent,
|
||||
};
|
||||
if id == me {
|
||||
return Err(anchor_to_self(who, verb));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The **XML** `<Anchor relativeTo="name">` resolution — `CLayoutFrame::LoadXML 0x767800`'s own,
|
||||
/// which is a different law from the Lua ladder above and belongs to `crate::loader`.
|
||||
///
|
||||
/// `0x767964 call [eax+0x28]` is the same `0x76c700` the Lua string arm uses, so the *lookup* is
|
||||
/// identical; what differs is the miss. `0x767969 test ebx,ebx` → `0x76796d` pushes
|
||||
/// `0x878440 "Couldn't find relative frame: %s"` at severity 1 through the XML report's
|
||||
/// `[ecx+0xc]` and then `jmp 0x7679dd`, which lands **past** the `0x7679c8 call 0x767c70` — the
|
||||
/// anchor is reported and SKIPPED. It does not raise (there is no Lua state to unwind here) and it
|
||||
/// does not fall back to the parent, which is what benilla did until 2176.
|
||||
///
|
||||
/// `$parent` is already expanded by the caller, so no base is taken.
|
||||
pub(crate) fn resolve_xml_relative_to(lua: &Lua, name: &str) -> Option<Table> {
|
||||
// ONE `_G` read, and the wrapper it produced is the one handed back — re-reading `_G` to fetch
|
||||
// the table again would be a second `lua_gettable`, and an `__index` is allowed to answer
|
||||
// twice differently.
|
||||
let nt = prefetch_named_target(lua, name, None);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
resolve_named_target(&model, &nt)?;
|
||||
// The wrapper the ladder's TABLE arm takes — resolved here so the binding never sees a name
|
||||
// from the XML path and so its raise can never reach a loading document.
|
||||
nt.wrapper.clone()
|
||||
}
|
||||
@@ -520,6 +520,20 @@ fn create_region(
|
||||
|
||||
let wrapper = region_wrapper(lua, id)?;
|
||||
if let Some(name) = name {
|
||||
// **A leading `$parent` in the NAME is expanded, here as in XML** — `CreateTexture
|
||||
// 0x773a20` and `CreateFontString 0x773c30` build a synthetic node carrying
|
||||
// `name=<the Lua string>` and read it back through the same `GetAttribute(node, "name")` →
|
||||
// `CScriptRegion::SetName 0x76c650` the XML path uses, and `0x76c691` is one of that
|
||||
// expander's two call sites (wow-re `name-string-widget-resolution.md` §5/§6, whose
|
||||
// `name=` reader census names `0x773ba1` and `0x773dc4` — the addresses *inside* these two
|
||||
// bindings). A region's `$parent` is its OWNER frame, so the walk starts there.
|
||||
//
|
||||
// `pfQuest/browser.lua:723` is `pfBrowser.input:CreateTexture("$parentSearchIcon",
|
||||
// "OVERLAY")`, and every named widget FonzAppraiser builds is this idiom.
|
||||
let name = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
crate::framexml::resolve_name(&name, &super::parent_token_base(&model, Some(owner)))
|
||||
};
|
||||
publish_global(lua, &name, &wrapper)?;
|
||||
// Publish into the region-name registry too (first-wins, the frame rule) — this is what
|
||||
// lets a sibling region's SetPoint name us as its `relativeTo` (see `resolve_target`).
|
||||
|
||||
@@ -3,40 +3,26 @@
|
||||
//! (`GetLeft`/`GetRight`/`GetTop`/`GetBottom`). Split out of [`super`] purely for size — see
|
||||
//! its module doc for the shared id/handle plumbing and method-table wiring.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::{Model, SCREEN};
|
||||
use crate::widget::FrameHandle;
|
||||
|
||||
use super::{
|
||||
as_f32, decode_id, frame_handle_of, frame_parent_token_base, frame_wrapper, point_from_str,
|
||||
point_name, prefetch_named_target, resolve_named_target, NamedTarget,
|
||||
};
|
||||
use super::anchor_args::{parse_set_all_points, parse_set_point, resolve_rel_target, UNNAMED};
|
||||
use super::{frame_handle_of, frame_parent_token_base, frame_wrapper, point_name};
|
||||
|
||||
/// Phase 1 of a `relativeTo`/`SetAllPoints` target: when the argument is a **name string**, expand
|
||||
/// a leading `$parent` against `h`'s first named ancestor and read `_G` — all of it with **no**
|
||||
/// `Model` guard alive, because the reference's read is a gettable and an `__index` on `_G` can run
|
||||
/// Lua that calls straight back into a binding ([`NamedTarget`]).
|
||||
///
|
||||
/// Anything else needs no prefetch and answers `None`.
|
||||
fn prefetch_relative_to(
|
||||
lua: &Lua,
|
||||
arg: Option<&Value>,
|
||||
h: FrameHandle,
|
||||
) -> mlua::Result<Option<NamedTarget>> {
|
||||
let Some(Value::String(s)) = arg else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A name we cannot even read names nothing; the caller's miss path reports it as before.
|
||||
let Ok(raw) = s.to_str() else {
|
||||
return Ok(Some(NamedTarget::unreadable()));
|
||||
};
|
||||
let base = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
frame_parent_token_base(&model, h)
|
||||
};
|
||||
Ok(Some(prefetch_named_target(lua, raw.as_ref(), Some(&base))))
|
||||
/// The two things the shared ladder needs off the receiver, read under **one short** `Model`
|
||||
/// borrow that is dropped before the `_G` read: the name the reference puts in its error strings
|
||||
/// (`GetName`, else the literal `"<unnamed>"`), and the name a leading `$parent` expands to.
|
||||
fn frame_ladder_context(lua: &Lua, h: FrameHandle) -> (String, String) {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let who = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| UNNAMED.to_string());
|
||||
(who, frame_parent_token_base(&model, h))
|
||||
}
|
||||
|
||||
/// Populate `m`'s layout (anchor/size) methods (see the module doc).
|
||||
@@ -44,11 +30,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// Layout: SetPoint / ClearAllPoints / SetWidth / SetHeight / GetWidth / GetHeight
|
||||
m.set(
|
||||
"SetPoint",
|
||||
lua.create_function(
|
||||
|lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| {
|
||||
set_point(lua, &this, &p, [a2, a3, a4, a5])
|
||||
},
|
||||
)?,
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| set_point(lua, &this, &rest))?,
|
||||
)?;
|
||||
m.set(
|
||||
"ClearAllPoints",
|
||||
@@ -135,30 +117,17 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path).
|
||||
m.set(
|
||||
"SetAllPoints",
|
||||
lua.create_function(|lua, (this, target): (Table, Value)| {
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// The `_G` read runs before the guard — see `set_point` and `object::NamedTarget`.
|
||||
let named = prefetch_relative_to(lua, Some(&target), h)?;
|
||||
// `who`/`$parent` first, then the `_G` read, then the guard — see `set_point` and
|
||||
// `object::NamedTarget`.
|
||||
let (who, base) = frame_ladder_context(lua, h);
|
||||
let target = parse_set_all_points(lua, rest.front(), &base);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let rel_id: u32 = match &target {
|
||||
// Same id_to_region fallback as `set_point` above (and `region.rs`'s own
|
||||
// `resolve_target`, which already checked both) — a table wrapper may name a region.
|
||||
Value::Table(t) => decode_id(t)
|
||||
.ok()
|
||||
.filter(|id| {
|
||||
model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)
|
||||
})
|
||||
.unwrap_or_else(|| default_parent_id(&mut model, h)),
|
||||
Value::String(_) => {
|
||||
// `_G[name]` — the client's one widget namespace, frames and regions alike
|
||||
// (`object::NamedTarget`). The real XML anchors frames to REGIONS too (gossip
|
||||
// option rows → the greeting FontString), and both publish there.
|
||||
let nt = named.as_ref().expect("a String argument is prefetched");
|
||||
let hit = resolve_named_target(&model, nt);
|
||||
hit.unwrap_or_else(|| default_parent_id(&mut model, h))
|
||||
}
|
||||
_ => default_parent_id(&mut model, h),
|
||||
};
|
||||
let me = model.frame_id(h);
|
||||
let parent = default_parent_id(&mut model, h);
|
||||
let rel_id: u32 =
|
||||
resolve_rel_target(&model, &target, &who, "SetAllPoints", me, parent)?;
|
||||
let pair = [
|
||||
Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
|
||||
@@ -369,91 +338,22 @@ fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 {
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` (RF-0023/RF-0024). Defaults per the
|
||||
/// client: `relativeTo` = the frame's parent (the screen root if top-level), `relativePoint` =
|
||||
/// `point`. Overload disambiguated by argument *type* (a string/table after `point` is `relativeTo`;
|
||||
/// a number is the first offset).
|
||||
fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Result<()> {
|
||||
let point = point_from_str(point)
|
||||
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
|
||||
/// `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — `0x7a2540`, whose argument ladder
|
||||
/// (and whose five raises) live in [`super::anchor_args`] because the reference runs **one**
|
||||
/// function for frames and regions alike. What is this side's own is only what the reference reads
|
||||
/// off a *frame*: the layout parent's default id, and the anchor list the commit writes.
|
||||
fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
|
||||
// A name target is read out of `_G` FIRST, with no model guard alive — the reference's read is
|
||||
// a gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`).
|
||||
let named = prefetch_relative_to(lua, rest.first(), h)?;
|
||||
let (who, base) = frame_ladder_context(lua, h);
|
||||
// The `_G` read inside the ladder runs with NO model guard alive — the reference's read is a
|
||||
// gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`).
|
||||
let p = parse_set_point(lua, args, &who, &base)?;
|
||||
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
|
||||
// relativeTo: a table (wrapper) or a string (name), else default to the parent/screen. A table
|
||||
// wrapper may name a FRAME or a REGION (a Button anchored to a FontString by direct reference —
|
||||
// e.g. `button:SetPoint("TOPLEFT", someFontString, "BOTTOMLEFT", ...)` — is exactly as legal in
|
||||
// the real client as anchoring to a frame, and the string-branch below already accepts both), so
|
||||
// this must check id_to_region too, not just id_to_frame: an id that only lives in id_to_region
|
||||
// used to fail the frame-only filter and silently fall back to the default parent, misdirecting
|
||||
// the anchor with no error (caught via the quest-log reward rows, which anchor Buttons to
|
||||
// FontString regions by table reference — QuestLogFrame.xml's own comment on the port).
|
||||
let mut cursor = 0usize;
|
||||
let rel_to_id: u32 = match rest.first() {
|
||||
Some(Value::Table(t)) => {
|
||||
cursor = 1;
|
||||
decode_id(t)
|
||||
.ok()
|
||||
.filter(|id| {
|
||||
model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)
|
||||
})
|
||||
.unwrap_or_else(|| default_parent_id(&mut model, h))
|
||||
}
|
||||
Some(Value::String(_)) => {
|
||||
cursor = 1;
|
||||
// `_G[name]`, frames and regions alike (see SetAllPoints above — same rationale), with
|
||||
// a leading `$parent` already expanded against this frame's first named ancestor.
|
||||
let nt = named.as_ref().expect("a String argument is prefetched");
|
||||
let hit = resolve_named_target(&model, nt);
|
||||
hit.unwrap_or_else(|| {
|
||||
// **The reference RAISES here** — `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't
|
||||
// find region named '%s'")`, no fallback and no no-op (`luaG_errormsg`/`luaD_throw`
|
||||
// contain no `ret`). Ours still falls back to the parent and warns, because we
|
||||
// cannot yet see how many of our own transcriptions and of the addon corpus that
|
||||
// raise would abort — the harness reports no engine warnings, which is the
|
||||
// instrument that has to come first (decision 2105, "Named, not done").
|
||||
//
|
||||
// The warning is worth keeping regardless: a *named* target that doesn't resolve is
|
||||
// almost always a bug, and this exact silence cost a hunt twice (QuestLogFrame's
|
||||
// reward rows, ItemTextFrame's scrollbar track).
|
||||
let who = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| "<anonymous>".into());
|
||||
model.record_warning(format!(
|
||||
"SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent",
|
||||
nt.name
|
||||
));
|
||||
default_parent_id(&mut model, h)
|
||||
})
|
||||
}
|
||||
// An *explicit* `nil` relativeTo (`SetPoint(point, nil, relPoint, x, y)`) means "default
|
||||
// target" but still occupies its argument slot — consume it so the relativePoint/offsets
|
||||
// that follow line up. Only a leading *number* (the `SetPoint(point, x, y)` overload) or a
|
||||
// truly-absent arg leaves the cursor at 0.
|
||||
Some(Value::Nil) => {
|
||||
cursor = 1;
|
||||
default_parent_id(&mut model, h)
|
||||
}
|
||||
_ => default_parent_id(&mut model, h),
|
||||
};
|
||||
|
||||
// relativePoint: a string in the next slot, else defaults to `point`.
|
||||
let mut rel_point = point;
|
||||
if let Some(Value::String(s)) = rest.get(cursor) {
|
||||
if let Some(p) = s.to_str().ok().and_then(|n| point_from_str(n.as_ref())) {
|
||||
rel_point = p;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let x = rest.get(cursor).map(as_f32).unwrap_or(0.0);
|
||||
let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0);
|
||||
let me = model.frame_id(h);
|
||||
let parent = default_parent_id(&mut model, h);
|
||||
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, parent)?;
|
||||
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
|
||||
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! axis — frames grow per-kind method tables ([`super::statusbar`], [`super::button`]), regions
|
||||
//! grow paint/coords methods here.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED};
|
||||
use super::object::{
|
||||
anchor_bits_eq, anchor_retarget_is_structural, as_f32, decode_id, id_to_lud, point_from_str,
|
||||
NamedTarget,
|
||||
anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget,
|
||||
};
|
||||
use super::{
|
||||
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
|
||||
@@ -716,77 +716,27 @@ pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 1 of a region's `relativeTo`/`SetAllPoints` target — the frame twin is
|
||||
/// `object::layout_methods`' `prefetch_relative_to`, and the reason is the same: the reference
|
||||
/// reads `_G` with a *gettable*, so the read must happen with **no** `Model` guard alive
|
||||
/// ([`super::object::NamedTarget`]).
|
||||
/// The two things the shared ladder needs off a **region** receiver, read under one short `Model`
|
||||
/// borrow that is dropped before the `_G` read (`super::object::anchor_args`'s contract, and the
|
||||
/// frame twin's `frame_ladder_context`): the name the reference puts in its error strings, and the
|
||||
/// name a leading `$parent` expands to.
|
||||
///
|
||||
/// A region's `$parent` is its **owner** frame (the region's own `+0x9c`), so the walk starts
|
||||
/// there rather than one link higher.
|
||||
pub(super) fn prefetch_region_target(
|
||||
lua: &Lua,
|
||||
target: &Value,
|
||||
rh: RegionHandle,
|
||||
) -> Option<NamedTarget> {
|
||||
let Value::String(s) = target else {
|
||||
return None;
|
||||
};
|
||||
let Ok(raw) = s.to_str() else {
|
||||
return Some(NamedTarget::unreadable());
|
||||
};
|
||||
let base = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let owner = model.arena.region(rh).map(|r| r.owner);
|
||||
super::object::parent_token_base(&model, owner)
|
||||
};
|
||||
Some(super::object::prefetch_named_target(
|
||||
lua,
|
||||
raw.as_ref(),
|
||||
Some(&base),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a
|
||||
/// widget name prefetched by [`prefetch_region_target`], or nil) to a layout id, defaulting to
|
||||
/// `owner` when absent/unresolved.
|
||||
pub(super) fn resolve_target(
|
||||
model: &mut Model,
|
||||
target: &Value,
|
||||
named: Option<&NamedTarget>,
|
||||
owner: u32,
|
||||
) -> u32 {
|
||||
match target {
|
||||
Value::Table(t) => decode_id(t)
|
||||
.ok()
|
||||
.filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id))
|
||||
.unwrap_or(owner),
|
||||
Value::String(_) => {
|
||||
// The client's global namespace is ONE, so a frame and a region answer the same
|
||||
// lookup — the real XML anchors regions to sibling regions by name too (merchant
|
||||
// label plate → `$parentSlot`, and that `$parent` is expanded on this path).
|
||||
let nt = named.expect("a String argument is prefetched");
|
||||
let hit = super::object::resolve_named_target(model, nt);
|
||||
hit.unwrap_or_else(|| {
|
||||
// The reference RAISES on this leg (`0x87ccd4`); ours warns and falls back to the
|
||||
// owner — the frame twin carries the why, and decision 2105 the deferral. The
|
||||
// warning earns its keep either way: a *named* target that doesn't resolve is
|
||||
// almost always a bug (a typo, or an XML forward reference — ItemTextFrame's
|
||||
// scrollbar track landed on the parchment this way).
|
||||
let who = model
|
||||
.id_to_frame
|
||||
.get(&owner)
|
||||
.and_then(|&h| model.arena.frame(h))
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_else(|| "<anonymous>".into());
|
||||
model.record_warning(format!(
|
||||
"SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner",
|
||||
nt.name
|
||||
));
|
||||
owner
|
||||
})
|
||||
}
|
||||
_ => owner,
|
||||
}
|
||||
/// there rather than one link higher. Its own name comes back out of `Model::region_names` — the
|
||||
/// registry is name→id, and this is the only place that wants the inverse, so it is a scan on the
|
||||
/// **error path** rather than a second map kept in step.
|
||||
pub(super) fn region_ladder_context(lua: &Lua, rh: RegionHandle) -> (String, String) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let id = model.region_id(rh);
|
||||
let who = model
|
||||
.region_names
|
||||
.iter()
|
||||
.find(|(_, &v)| v == id)
|
||||
.map(|(n, _)| n.clone())
|
||||
.unwrap_or_else(|| UNNAMED.to_string());
|
||||
let owner = model.arena.region(rh).map(|r| r.owner);
|
||||
let base = super::object::parent_token_base(&model, owner);
|
||||
(who, base)
|
||||
}
|
||||
|
||||
/// Bit-exact equality for a region's explicit size — the layout gate's own lens
|
||||
@@ -802,45 +752,22 @@ pub(super) fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool
|
||||
}
|
||||
}
|
||||
|
||||
/// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the region twin of
|
||||
/// [`super::object`]'s frame `SetPoint`, writing [`super::RegionData::anchors`]. The overload is
|
||||
/// disambiguated by argument *type* exactly as the frame version.
|
||||
pub(super) fn region_set_point(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
point: &str,
|
||||
rest: [Value; 4],
|
||||
) -> mlua::Result<()> {
|
||||
let point = point_from_str(point)
|
||||
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
|
||||
/// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the *same* binding as the
|
||||
/// frame's (`0x7a2540` is registered once, on `CScriptRegion`), so the argument ladder and every
|
||||
/// one of its raises come from [`super::object::anchor_args`]; this side supplies only what the
|
||||
/// reference reads off a region — the owner as the layout parent, and
|
||||
/// [`super::RegionData::anchors`] as the store.
|
||||
pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
|
||||
let rh = region_handle_of(lua, this)?;
|
||||
// The `_G` read runs before the guard — see `prefetch_region_target`.
|
||||
let named = rest
|
||||
.first()
|
||||
.and_then(|v| prefetch_region_target(lua, v, rh));
|
||||
let (who, base) = region_ladder_context(lua, rh);
|
||||
// The `_G` read inside the ladder runs with no model guard alive — see `region_ladder_context`.
|
||||
let p = parse_set_point(lua, args, &who, &base)?;
|
||||
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let me = model.region_id(rh);
|
||||
let owner = region_owner_id(&mut model, rh);
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let rel_to_id: u32 = match rest.first() {
|
||||
Some(Value::Table(_) | Value::String(_) | Value::Nil) => {
|
||||
cursor = 1;
|
||||
resolve_target(&mut model, &rest[0], named.as_ref(), owner)
|
||||
}
|
||||
// A leading number is the `SetPoint(point, x, y)` overload — cursor stays at 0.
|
||||
_ => owner,
|
||||
};
|
||||
|
||||
let mut rel_point = point;
|
||||
if let Some(Value::String(s)) = rest.get(cursor) {
|
||||
if let Some(p) = s.to_str().ok().and_then(|n| point_from_str(n.as_ref())) {
|
||||
rel_point = p;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let x = rest.get(cursor).map(as_f32).unwrap_or(0.0);
|
||||
let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0);
|
||||
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, owner)?;
|
||||
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
|
||||
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
//! Region method-table cluster: **layout** — size, anchors and the resolved-rect readers.
|
||||
//! Split out of `region.rs` at the 0716 file-size budget.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::object::anchor_args::{parse_set_all_points, resolve_rel_target};
|
||||
use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name};
|
||||
use crate::script::{Model, SCREEN};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
use super::{
|
||||
measured_wh, region_handle_of, region_owner_id, region_set_point, resolve_target, size_bits_eq,
|
||||
measured_wh, region_handle_of, region_ladder_context, region_owner_id, region_set_point,
|
||||
size_bits_eq,
|
||||
};
|
||||
|
||||
/// Populate `m`'s layout methods (see the module doc).
|
||||
@@ -106,11 +108,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`).
|
||||
m.set(
|
||||
"SetPoint",
|
||||
lua.create_function(
|
||||
|lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| {
|
||||
region_set_point(lua, &this, &p, [a2, a3, a4, a5])
|
||||
},
|
||||
)?,
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
region_set_point(lua, &this, &rest)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
@@ -134,13 +134,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
|
||||
m.set(
|
||||
"SetAllPoints",
|
||||
lua.create_function(|lua, (this, target): (Table, Value)| {
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
// The `_G` read runs before the guard — see `region::prefetch_region_target`.
|
||||
let named = super::prefetch_region_target(lua, &target, rh);
|
||||
// `who`/`$parent` first, then the `_G` read, then the guard — `region_ladder_context`.
|
||||
let (who, base) = region_ladder_context(lua, rh);
|
||||
let target = parse_set_all_points(lua, rest.front(), &base);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let me = model.region_id(rh);
|
||||
let owner = region_owner_id(&mut model, rh);
|
||||
let rel_id = resolve_target(&mut model, &target, named.as_ref(), owner);
|
||||
let rel_id = resolve_rel_target(&model, &target, &who, "SetAllPoints", me, owner)?;
|
||||
let pair = [
|
||||
Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
|
||||
|
||||
@@ -31,6 +31,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.
|
||||
s.note_addon_info_reply(&[]);
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! **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. wow-re
|
||||
//! `system/ui/scratch/addon-registry-scan-and-order.md` §7 (byte-read, and §11-executed over the
|
||||
//! binary's own `qsort 0x73f727` and comparator `0x51deb0`).
|
||||
|
||||
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"`).
|
||||
fn seeded() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let row = |name: &str, title: Option<&str>| AddOnInfo {
|
||||
name: name.into(),
|
||||
title: title.map(str::to_owned),
|
||||
interface: 11200,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
s.register_addons(
|
||||
vec![
|
||||
row("Zulu", Some("aardvark")),
|
||||
row("Alpha", Some("zebra")),
|
||||
row("Mike", Some("Middle")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
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",
|
||||
)
|
||||
.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`.
|
||||
#[test]
|
||||
fn the_index_space_is_title_sorted_case_insensitively_and_not_registry_order() {
|
||||
let mut s = seeded();
|
||||
s.note_addon_info_reply(&[]);
|
||||
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.
|
||||
#[test]
|
||||
fn a_titleless_addon_sorts_under_its_folder_name() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.register_addons(
|
||||
vec![
|
||||
AddOnInfo {
|
||||
name: "Yankee".into(),
|
||||
title: Some("zzz".into()),
|
||||
interface: 11200,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
// No title at all — sorts as "bravo", between nothing and "zzz".
|
||||
AddOnInfo {
|
||||
name: "bravo".into(),
|
||||
interface: 11200,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
s.note_addon_info_reply(&[]);
|
||||
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.
|
||||
#[test]
|
||||
fn there_is_no_index_space_until_the_server_answers() {
|
||||
let s = seeded();
|
||||
assert_eq!(s.eval::<i64>("return GetNumAddOns()").ok(), Some(0));
|
||||
let raised = s
|
||||
.eval::<String>(
|
||||
"local ok, e = pcall(function() GetAddOnInfo(1) end) \
|
||||
if ok then return \"<no raise>\" end return tostring(e)",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
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.
|
||||
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.
|
||||
#[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
|
||||
assert_eq!(s.eval::<i64>("return GetNumAddOns()").ok(), Some(2));
|
||||
assert_eq!(by_index(&s), vec!["Zulu", "Alpha"]);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetAddOnInfo('Mike'))").ok(),
|
||||
Some("Mike".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// **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();
|
||||
assert_eq!(s.eval::<i64>("return GetNumAddOns()").ok(), Some(0));
|
||||
s.note_addon_info_reply(&[]);
|
||||
assert_eq!(s.eval::<i64>("return GetNumAddOns()").ok(), Some(3));
|
||||
}
|
||||
@@ -75,39 +75,157 @@ fn getwidth_falls_back_to_explicit_size_before_resolve() {
|
||||
assert_eq!(w, 123.0);
|
||||
}
|
||||
|
||||
// A *named* relativeTo that doesn't resolve falls back to the parent/owner — the client's
|
||||
// behavior — but must SAY so: the silent version misdirected ItemTextFrame's scrollbar track
|
||||
// onto the parchment (an XML forward reference; anchors resolve at SetPoint time). Both the
|
||||
// frame and the region SetPoint paths warn.
|
||||
/// 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`).
|
||||
#[test]
|
||||
fn setpoint_unresolved_name_warns() {
|
||||
fn setpoint_unresolved_name_raises() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Orphan")
|
||||
f:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0)
|
||||
local t = f:CreateTexture(nil, "ARTWORK")
|
||||
t:SetPoint("TOPRIGHT", "NoSuchRegion")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let w = s.take_warnings();
|
||||
s.run(r#"f = CreateFrame("Frame", "Orphan")"#).unwrap();
|
||||
|
||||
let e = s
|
||||
.run(r#"Orphan:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0)"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
w.iter()
|
||||
.any(|w| w.contains("Orphan") && w.contains("NoSuchFrame")),
|
||||
"frame path: {w:#?}"
|
||||
e.contains("Orphan:SetPoint(): Couldn't find region named 'NoSuchFrame'"),
|
||||
"frame path: {e}"
|
||||
);
|
||||
|
||||
let e = s
|
||||
.run(
|
||||
r#"t = Orphan:CreateTexture(nil, "ARTWORK")
|
||||
t:SetPoint("TOPRIGHT", "NoSuchRegion")"#,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
w.iter()
|
||||
.any(|w| w.contains("Orphan") && w.contains("NoSuchRegion")),
|
||||
"region path: {w:#?}"
|
||||
e.contains("<unnamed>:SetPoint(): Couldn't find region named 'NoSuchRegion'"),
|
||||
"region path (anonymous receiver): {e}"
|
||||
);
|
||||
// A resolvable name stays silent.
|
||||
|
||||
// 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.
|
||||
let e = s
|
||||
.run(r#"Orphan:SetAllPoints("NoSuchFrame")"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
e.contains("Orphan:SetAllPoints(): Couldn't find region named 'NoSuchFrame'"),
|
||||
"{e}"
|
||||
);
|
||||
let e = s.run("Orphan:SetAllPoints(42)").unwrap_err().to_string();
|
||||
assert!(
|
||||
e.contains("Orphan:SetAllPoints(): Couldn't find region named '42'"),
|
||||
"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".
|
||||
#[test]
|
||||
fn the_other_setpoint_raises() {
|
||||
let s = script();
|
||||
s.run(r#"f = CreateFrame("Frame", "Ladder")"#).unwrap();
|
||||
|
||||
let e = s
|
||||
.run(r#"Ladder:SetPoint("CENTER")"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("Usage: Ladder:SetPoint(\"point\""), "{e}");
|
||||
|
||||
let e = s
|
||||
.run(r#"Ladder:SetPoint("MIDDLE", nil, "MIDDLE", 0, 0)"#)
|
||||
.unwrap_err()
|
||||
.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()
|
||||
.to_string();
|
||||
assert!(e.contains("Usage:"), "the tag gate runs first: {e}");
|
||||
|
||||
let e = s
|
||||
.run(r#"Ladder:SetPoint("CENTER", Ladder, "CENTER", 0, 0)"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
e.contains("Ladder:SetPoint(): trying to anchor to itself"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **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.
|
||||
#[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()
|
||||
.to_string();
|
||||
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).
|
||||
#[test]
|
||||
fn an_explicit_nil_relative_to_is_the_screen_not_the_parent() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
host = CreateFrame("Frame", "NilHost")
|
||||
NilHost:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 200, 100)
|
||||
NilHost:SetWidth(100) NilHost:SetHeight(100)
|
||||
kid = CreateFrame("Frame", "NilKid", NilHost)
|
||||
NilKid:SetWidth(10) NilKid:SetHeight(10)
|
||||
NilKid:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let left: f32 = s.eval("return NilKid:GetLeft()").unwrap();
|
||||
assert_eq!(
|
||||
left, 0.0,
|
||||
"the screen's BOTTOMLEFT, not NilHost's (which is 200)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **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.
|
||||
@@ -141,7 +259,7 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
|
||||
|
||||
-- control: identical region, owner that DOES resolve
|
||||
sized = CreateFrame("Frame", "SizedOwner")
|
||||
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER")
|
||||
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER", 0, 0)
|
||||
ctl = sized:CreateTexture("SizedMark", "ARTWORK")
|
||||
ctl:SetWidth(20) ctl:SetHeight(10)
|
||||
ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
|
||||
@@ -201,7 +319,7 @@ fn an_unanchored_owners_region_chain_resolves_nowhere() {
|
||||
-- $parentLeft: anchored to the OWNER (no relativeTo = the owner frame), which has no rect.
|
||||
cap = bare:CreateTexture("StrayLeft", "ARTWORK")
|
||||
cap:SetWidth(25) cap:SetHeight(64)
|
||||
cap:SetPoint("TOPLEFT")
|
||||
cap:SetPoint("TOPLEFT", 0, 0)
|
||||
|
||||
-- $parentMiddle: the sibling chain that turned a zero rect into 115x64 of visible capsule.
|
||||
mid = bare:CreateTexture("StrayMiddle", "ARTWORK")
|
||||
|
||||
@@ -132,7 +132,7 @@ fn get_backdrop_round_trips_through_set_backdrop() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "BC2Plate")
|
||||
f:SetPoint("CENTER")
|
||||
f:SetPoint("CENTER", 0, 0)
|
||||
f:SetWidth(54); f:SetHeight(54)
|
||||
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true,
|
||||
tileSize = 32, edgeSize = 32,
|
||||
@@ -169,7 +169,7 @@ fn set_backdrop_nil_tears_down() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Plate2")
|
||||
f:SetPoint("CENTER")
|
||||
f:SetPoint("CENTER", 0, 0)
|
||||
f:SetWidth(100); f:SetHeight(100)
|
||||
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
|
||||
f:SetBackdrop(nil)
|
||||
|
||||
@@ -255,7 +255,7 @@ fn set_font_object_takes_the_object_or_the_name() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "TwoWays")
|
||||
f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER")
|
||||
f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER", 0, 0)
|
||||
byObject = f:CreateFontString(nil, "ARTWORK")
|
||||
byObject:SetText("obj")
|
||||
byObject:SetFontObject(GameFontHighlightSmall)
|
||||
@@ -320,7 +320,7 @@ fn dewdrop_recolors_a_row_from_its_own_font_object() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "DdRow")
|
||||
f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER")
|
||||
f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER", 0, 0)
|
||||
button = { text = f:CreateFontString(nil, "ARTWORK") }
|
||||
button.text:SetText("row")
|
||||
button.text:SetFontObject(GameFontHighlightSmall)
|
||||
@@ -362,7 +362,7 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "ThemeHost")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER", 0, 0)
|
||||
a = f:CreateFontString(nil, "ARTWORK"); a:SetText("inherited")
|
||||
a:SetFontObject(ThemeFont)
|
||||
b = f:CreateFontString(nil, "ARTWORK"); b:SetText("overridden")
|
||||
@@ -445,7 +445,7 @@ fn propagation_is_scoped_to_the_object_that_changed() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "ScopeHost")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER", 0, 0)
|
||||
one = f:CreateFontString(nil, "ARTWORK"); one:SetText("one"); one:SetFontObject(FontOne)
|
||||
two = f:CreateFontString(nil, "ARTWORK"); two:SetText("two"); two:SetFontObject(FontTwo)
|
||||
FontOne:SetFont("Fonts\\CHANGED.TTF", 30)
|
||||
@@ -477,7 +477,7 @@ fn create_font_mints_publishes_and_paints() {
|
||||
Nameplate.Font:SetTextColor(0, 0.5, 1)
|
||||
|
||||
f = CreateFrame("Frame", "PlateHost")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER", 0, 0)
|
||||
Name = f:CreateFontString(nil, "ARTWORK")
|
||||
Name:SetText("plate")
|
||||
Name:SetFontObject(Nameplate.Font)
|
||||
@@ -626,7 +626,7 @@ fn button_state_fonts_take_the_object_and_follow_its_mutation() {
|
||||
s.run(
|
||||
r#"
|
||||
b = CreateFrame("Button", "StateFontButton")
|
||||
b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER")
|
||||
b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER", 0, 0)
|
||||
b:SetText("go")
|
||||
b:SetTextFontObject(GameFontNormal)
|
||||
"#,
|
||||
@@ -671,7 +671,7 @@ fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "EmptyFontHost")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER", 0, 0)
|
||||
fs = f:CreateFontString(nil, "ARTWORK")
|
||||
fs:SetText("keep")
|
||||
fs:SetFontObject(DressedFont)
|
||||
@@ -859,7 +859,7 @@ fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
"f = CreateFrame('Frame', 'ClearAxisFrame')\n\
|
||||
f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER')\n\
|
||||
f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER', 0, 0)\n\
|
||||
fs = f:CreateFontString()\n\
|
||||
fs:SetAllPoints(f)\n\
|
||||
fs:SetText('erased')",
|
||||
@@ -924,9 +924,9 @@ fn the_font_block_reaches_both_message_frame_tables() {
|
||||
"fo = CreateFont('MsgBlockFont')\n\
|
||||
fo:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
|
||||
mf = CreateFrame('MessageFrame', 'MsgBlockPlain')\n\
|
||||
mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER')\n\
|
||||
mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER', 0, 0)\n\
|
||||
smf = CreateFrame('ScrollingMessageFrame', 'MsgBlockScroll')\n\
|
||||
smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT')",
|
||||
smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT', 0, 0)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -382,9 +382,9 @@ fn a_width_read_in_the_tick_that_set_the_text_is_not_zero() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Host")
|
||||
f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300)
|
||||
f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300)
|
||||
local fs = f:CreateFontString("Label", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT")
|
||||
fs:SetPoint("TOPLEFT", 0, 0)
|
||||
-- the corpus idiom: set, then measure, in one statement sequence
|
||||
fs:SetText("Onewarrior")
|
||||
Answer = fs:GetStringWidth()
|
||||
@@ -408,9 +408,9 @@ fn with_no_engine_installed_the_round_trip_is_still_the_only_answer() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Host")
|
||||
f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300)
|
||||
f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300)
|
||||
local fs = f:CreateFontString("Label", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT")
|
||||
fs:SetPoint("TOPLEFT", 0, 0)
|
||||
fs:SetText("Onewarrior")
|
||||
Answer = fs:GetStringWidth()
|
||||
"#,
|
||||
@@ -435,9 +435,9 @@ fn resolve_closes_the_round_trip_when_an_engine_is_installed() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Host")
|
||||
f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300)
|
||||
f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300)
|
||||
local fs = f:CreateFontString("Label", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT")
|
||||
fs:SetPoint("TOPLEFT", 0, 0)
|
||||
fs:SetWidth(35) -- a declared width ⇒ the two extents differ
|
||||
fs:SetText("Onewarrior") -- 70 natural, wraps to 2 rows inside 35
|
||||
"#,
|
||||
@@ -469,9 +469,9 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Host")
|
||||
f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300)
|
||||
f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300)
|
||||
local fs = f:CreateFontString("Label", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT")
|
||||
fs:SetPoint("TOPLEFT", 0, 0)
|
||||
fs:SetText("hello")
|
||||
Answer = fs:GetStringWidth()
|
||||
"#,
|
||||
@@ -505,9 +505,9 @@ fn a_settled_region_is_refound_after_each_measure_input_write() {
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Host")
|
||||
f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300)
|
||||
f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300)
|
||||
local fs = f:CreateFontString("Label", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT")
|
||||
fs:SetPoint("TOPLEFT", 0, 0)
|
||||
fs:SetText("one")
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ fn minimap_zoom_api_and_extract() {
|
||||
s.run(
|
||||
r#"
|
||||
m = CreateFrame("Minimap", "TestMinimap")
|
||||
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT")
|
||||
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -80,7 +80,7 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
|
||||
s.run(
|
||||
r#"
|
||||
m = CreateFrame("Minimap", "TestMinimap")
|
||||
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT")
|
||||
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Split by subject; the shared `script()` fixture lives in [`common`].
|
||||
|
||||
mod addon_argument_abi;
|
||||
mod addon_index_space;
|
||||
mod anchors;
|
||||
mod backdrop;
|
||||
mod button;
|
||||
|
||||
@@ -435,9 +435,9 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
s.run(
|
||||
r#"
|
||||
a = CreateFrame("Model", "Sized", UIParent)
|
||||
a:SetPoint("CENTER")
|
||||
a:SetPoint("CENTER", 0, 0)
|
||||
b = CreateFrame("Model", "Authored", UIParent)
|
||||
b:SetPoint("CENTER") b:SetWidth(50) b:SetHeight(20)
|
||||
b:SetPoint("CENTER", 0, 0) b:SetWidth(50) b:SetHeight(20)
|
||||
a:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
|
||||
b:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
|
||||
"#,
|
||||
|
||||
@@ -107,8 +107,9 @@ 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. The
|
||||
/// binding then takes its usual unresolved-target path (parent + a warning), never the value.
|
||||
/// 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.
|
||||
#[test]
|
||||
fn setpoint_relative_to_ignores_a_non_widget_global() {
|
||||
let mut s = script();
|
||||
@@ -121,20 +122,19 @@ fn setpoint_relative_to_ignores_a_non_widget_global() {
|
||||
NotAWidget = 5
|
||||
Child = CreateFrame("Frame", "Child", Host)
|
||||
Child:SetWidth(20); Child:SetHeight(20)
|
||||
Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Fell back to the parent Host, whose BOTTOMRIGHT is (110, 10).
|
||||
let left: f32 = s.eval("return Child:GetLeft()").unwrap();
|
||||
assert_eq!(left, 110.0);
|
||||
let warnings = s.take_warnings();
|
||||
let e = s
|
||||
.run(r#"Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0)"#)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("NotAWidget")),
|
||||
"an unresolved named target is reported, not silently redirected: {warnings:?}"
|
||||
e.contains("Child:SetPoint(): Couldn't find region named 'NotAWidget'"),
|
||||
"a global holding a non-widget names nothing: {e}"
|
||||
);
|
||||
let n: i64 = s.eval("return Child:GetNumPoints()").unwrap();
|
||||
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
|
||||
@@ -242,3 +242,49 @@ fn setallpoints_resolves_an_alias_global() {
|
||||
.unwrap();
|
||||
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")`.
|
||||
#[test]
|
||||
fn a_constructor_name_expands_parent() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
Root = CreateFrame("Frame", "Root")
|
||||
Mid = CreateFrame("Frame", nil, Root) -- anonymous: the walk skips it
|
||||
Kid = CreateFrame("Frame", "$parentKid", Mid)
|
||||
Icon = Kid:CreateTexture("$parentIcon", "OVERLAY")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Kid:GetName()").unwrap(),
|
||||
"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).
|
||||
assert_eq!(
|
||||
s.eval::<String>("return RootKid:GetName()").unwrap(),
|
||||
"RootKid"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("$parentKid") == nil"#)
|
||||
.unwrap(),
|
||||
"the raw token names nothing"
|
||||
);
|
||||
// A region's `$parent` is its OWNER frame, so the walk starts one link lower.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return RootKidIcon:GetName()").unwrap(),
|
||||
"RootKidIcon"
|
||||
);
|
||||
// The payoff: an anchor by the built name resolves instead of raising.
|
||||
s.run(r#"Icon:SetPoint("TOPLEFT", "RootKid", "TOPLEFT", 0, 0)"#)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -1025,9 +1025,9 @@ fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_hei
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50)
|
||||
Sized = f:CreateFontString("Sized", "ARTWORK")
|
||||
Sized:SetPoint("TOPLEFT"); Sized:SetWidth(300); Sized:SetText("Name")
|
||||
Sized:SetPoint("TOPLEFT", 0, 0); Sized:SetWidth(300); Sized:SetText("Name")
|
||||
Auto = f:CreateFontString("Auto", "ARTWORK")
|
||||
Auto:SetPoint("TOPLEFT"); Auto:SetText("Name")
|
||||
Auto:SetPoint("TOPLEFT", 0, 0); Auto:SetText("Name")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1067,9 +1067,9 @@ fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() {
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50)
|
||||
Pending = f:CreateFontString("Pending", "ARTWORK")
|
||||
Pending:SetPoint("TOPLEFT"); Pending:SetText("Name")
|
||||
Pending:SetPoint("TOPLEFT", 0, 0); Pending:SetText("Name")
|
||||
Empty = f:CreateFontString("Empty", "ARTWORK")
|
||||
Empty:SetPoint("TOPLEFT"); Empty:SetText("")
|
||||
Empty:SetPoint("TOPLEFT", 0, 0); Empty:SetText("")
|
||||
assert(Pending:GetHeight() == 0, "a pending measure is not a size, got " .. tostring(Pending:GetHeight()))
|
||||
assert(Empty:GetHeight() == 1, "an EMPTY string is one unit, got " .. tostring(Empty:GetHeight()))
|
||||
assert(Empty:GetWidth() == 1, "both axes")
|
||||
@@ -1093,11 +1093,11 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50)
|
||||
Art = f:CreateTexture("Art", "ARTWORK")
|
||||
Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT")
|
||||
Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT", 0, 0)
|
||||
Half = f:CreateTexture("Half", "ARTWORK")
|
||||
Half:SetTexture("Interface\\Crest"); Half:SetPoint("TOPLEFT"); Half:SetHeight(13)
|
||||
Half:SetTexture("Interface\\Crest"); Half:SetPoint("TOPLEFT", 0, 0); Half:SetHeight(13)
|
||||
Bare = f:CreateTexture("Bare", "ARTWORK")
|
||||
Bare:SetPoint("TOPLEFT")
|
||||
Bare:SetPoint("TOPLEFT", 0, 0)
|
||||
assert(Art:GetWidth() == 128 and Art:GetHeight() == 96, "the art's own texels")
|
||||
assert(Half:GetWidth() == 128 and Half:GetHeight() == 13, "per AXIS: authored 13 wins, width still derived")
|
||||
assert(Bare:GetWidth() == 0, "no art, no span — and no floor on a texture")
|
||||
|
||||
@@ -34,7 +34,7 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "TaxiHost")
|
||||
f:SetWidth(316); f:SetHeight(352); f:SetPoint("CENTER")
|
||||
f:SetWidth(316); f:SetHeight(352); f:SetPoint("CENTER", 0, 0)
|
||||
map = f:CreateTexture("TaxiMapTex", "OVERLAY")
|
||||
SetTaxiMap(map)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ fn set_tex_coord_changes_extracted_uv() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "TcF")
|
||||
f:SetWidth(100); f:SetHeight(100); f:SetPoint("CENTER")
|
||||
f:SetWidth(100); f:SetHeight(100); f:SetPoint("CENTER", 0, 0)
|
||||
t = f:CreateTexture("TcTex", "ARTWORK")
|
||||
t:SetTexture("Interface\\Foo")
|
||||
t:SetTexCoord(0.1, 0.6, 0.2, 0.8)
|
||||
@@ -107,7 +107,7 @@ fn set_font_object_repoints_fontstring() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "FoF")
|
||||
f:SetWidth(120); f:SetHeight(30); f:SetPoint("CENTER")
|
||||
f:SetWidth(120); f:SetHeight(30); f:SetPoint("CENTER", 0, 0)
|
||||
fs = f:CreateFontString("FoText", "ARTWORK")
|
||||
fs:SetText("Hi")
|
||||
fs:SetFontObject("Big")
|
||||
@@ -170,7 +170,7 @@ fn justify_v_defaults_middle_and_overrides() {
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "JvF")
|
||||
f:SetWidth(100); f:SetHeight(30); f:SetPoint("CENTER")
|
||||
f:SetWidth(100); f:SetHeight(30); f:SetPoint("CENTER", 0, 0)
|
||||
fs = f:CreateFontString("JvText", "ARTWORK")
|
||||
fs:SetText("Hi")
|
||||
"#,
|
||||
|
||||
@@ -590,6 +590,144 @@ 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 wow-re §5 dispatched for the CURSOR mechanism refuted
|
||||
/// both at the bytes (`system/ui/scratch/tooltip-cursor-anchor-law.md` §0.2/§0.3/§2; 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.
|
||||
#[test]
|
||||
fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Plate = CreateFrame("GameTooltip", "Plate")
|
||||
Owner = CreateFrame("Frame", "Owner")
|
||||
Owner:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 300, 300)
|
||||
Owner:SetWidth(40) Owner:SetHeight(40)
|
||||
Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
|
||||
Plate:SetWidth(50) Plate:SetHeight(20)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
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.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_PRESERVE")"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return Plate:GetLeft()").unwrap(),
|
||||
100.0,
|
||||
"mode 8 returns before the ClearAllPoints"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return Plate:GetNumPoints()").unwrap(), 1);
|
||||
|
||||
// ANCHOR_NONE (mode 7) clears — which is what makes `GameTooltip_SetDefaultAnchor` safe.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_NONE")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return Plate:GetNumPoints()").unwrap(),
|
||||
0,
|
||||
"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.
|
||||
s.run(r#"Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)"#)
|
||||
.unwrap();
|
||||
s.run("Plate:SetOwner(Owner)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
|
||||
"ANCHOR_LEFT",
|
||||
"the omitted anchorType is the binding's zero-initialised local, mode 0"
|
||||
);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return Plate:GetLeft()").unwrap(),
|
||||
250.0,
|
||||
"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.
|
||||
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_SIDEWAYS")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
|
||||
"ANCHOR_LEFT"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`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`.
|
||||
#[test]
|
||||
fn anchor_cursor_follows_the_cursor_every_frame() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Tip = CreateFrame("GameTooltip", "Tip")
|
||||
Host = CreateFrame("Frame", "Host")
|
||||
Tip:SetOwner(Host, "ANCHOR_CURSOR")
|
||||
Tip:AddLine("Copper Ore")
|
||||
-- An explicit size, because this VM has no text measurer: the auto-size pre-pass leaves a
|
||||
-- plate with no measured extents at its declared size, and the placement is what is under
|
||||
-- test, not the width.
|
||||
Tip:SetWidth(120) Tip:SetHeight(40)
|
||||
Tip:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Tip:GetAnchorType()").unwrap(),
|
||||
"ANCHOR_CURSOR",
|
||||
"the mode round-trips; it is no longer recorded as whatever it was placed by"
|
||||
);
|
||||
assert!(
|
||||
!s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")),
|
||||
"a mode we honour is not warned about: {:?}",
|
||||
s.warnings()
|
||||
);
|
||||
|
||||
s.mouse_move(300.0, 200.0);
|
||||
s.resolve();
|
||||
let (left, right, bottom): (f32, f32, f32) = s
|
||||
.eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()")
|
||||
.unwrap();
|
||||
assert_eq!(bottom, 200.0, "the plate's BOTTOM sits at the cursor");
|
||||
assert!(
|
||||
((left + right) / 2.0 - 300.0).abs() < 0.001,
|
||||
"centred horizontally on the cursor: {left}..{right}"
|
||||
);
|
||||
|
||||
// It follows. The whole point of mode 6 is that no second SetOwner is needed.
|
||||
s.mouse_move(120.0, 480.0);
|
||||
s.resolve();
|
||||
let (left, right, bottom): (f32, f32, f32) = s
|
||||
.eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()")
|
||||
.unwrap();
|
||||
assert_eq!(bottom, 480.0);
|
||||
assert!(
|
||||
((left + right) / 2.0 - 120.0).abs() < 0.001,
|
||||
"{left}..{right}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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`).
|
||||
@@ -625,7 +763,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
"a plate nothing has owned is anchored to nothing"
|
||||
);
|
||||
|
||||
// The eight modes SetOwner accepts, each answered back verbatim — ANCHOR_PRESERVE included,
|
||||
// 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.
|
||||
for mode in [
|
||||
"ANCHOR_RIGHT",
|
||||
@@ -634,6 +772,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
"ANCHOR_TOPLEFT",
|
||||
"ANCHOR_BOTTOMRIGHT",
|
||||
"ANCHOR_BOTTOMLEFT",
|
||||
"ANCHOR_CURSOR",
|
||||
"ANCHOR_NONE",
|
||||
"ANCHOR_PRESERVE",
|
||||
] {
|
||||
@@ -656,20 +795,19 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() {
|
||||
"ANCHOR_LEFT"
|
||||
);
|
||||
|
||||
// An anchor SetOwner cannot honour is recorded as the ANCHOR_RIGHT it is actually PLACED by,
|
||||
// never as the string it was handed — the getter reports the plate, not the request.
|
||||
// `ANCHOR_CURSOR` is the live case (nine corpus files ask for it; mode 6 follows the cursor per
|
||||
// frame and this engine has no such driver), and SetOwner warns rather than pretending.
|
||||
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_CURSOR")"#)
|
||||
// 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.
|
||||
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_SIDEWAYS")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return AnchorTip:GetAnchorType()")
|
||||
.unwrap(),
|
||||
"ANCHOR_RIGHT"
|
||||
"ANCHOR_LEFT"
|
||||
);
|
||||
assert!(
|
||||
s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")),
|
||||
"the unhonoured anchor is warned, not silently taken: {:?}",
|
||||
s.warnings().iter().any(|w| w.contains("ANCHOR_SIDEWAYS")),
|
||||
"the unrecognised anchor is still reported to us: {:?}",
|
||||
s.warnings()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ fn minimap_blip_tooltip_shows_and_fades() {
|
||||
s.run(
|
||||
r#"
|
||||
local up = CreateFrame("Frame", "UIParent")
|
||||
up:SetPoint("BOTTOMLEFT"); up:SetWidth(800); up:SetHeight(600)
|
||||
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetWidth(800); up:SetHeight(600)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -33,8 +33,8 @@ use super::{FontObject, Model, RegionData};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::order::DrawLayer;
|
||||
use crate::widget::{
|
||||
FrameHandle, KindState, RegionKind, TooltipState, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
|
||||
TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
FrameHandle, KindState, RegionKind, TooltipAnchor, TooltipState, TOOLTIP_DOUBLE_GAP,
|
||||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
};
|
||||
|
||||
/// Registry key of the GameTooltip method table (the MAXCSTACK discipline: named registry root).
|
||||
@@ -673,6 +673,67 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
|
||||
/// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs
|
||||
/// a second time with real extents); without one it is the host's batch round-trip, one frame
|
||||
/// later, which is the engine-less path every measurer-free VM still takes.
|
||||
/// **ANCHOR_CURSOR — mode 6, re-anchored to the live cursor every frame** (`0x530b20`, the
|
||||
/// `CGameTooltip` override of `vtable+0x38` on `0x808f60`; wow-re
|
||||
/// `system/ui/scratch/tooltip-cursor-anchor-law.md` §1, three independent derivations agreeing
|
||||
/// instruction-for-instruction). Nine corpus files ask for it — `pfUI`'s tooltip, xpbar and chat
|
||||
/// modules, `pfQuest/browser.lua`, `TipBuddy` — and until decision 2176 benilla warned and placed
|
||||
/// them by some other mode.
|
||||
///
|
||||
/// The whole of the re-anchor is one `SetPoint`:
|
||||
///
|
||||
/// ```text
|
||||
/// 530b32 cmp [this+0x318],6 ; jne -- every other mode skips
|
||||
/// 530b3b eax = [this+0xa0] -- the CSimpleTop root == [0xcf0bd8]
|
||||
/// 530b4f ecx = [eax+0x111c] ; edx = [eax+0x1118] -- the SAVED cursor, normalised [0,1]
|
||||
/// 530b63 call 0x41ad80 -- x *= G44 (screen w), y *= G48 (screen h)
|
||||
/// 530b68 fld [this+0x7c] ; fdivr 1.0f -- 1 / the tooltip's own effective scale
|
||||
/// 530b97 call 0x767c70(BOTTOM=7, [0xcf0bd8], BOTTOMLEFT=6, x/scale, y/scale, doResolve=1)
|
||||
/// ```
|
||||
///
|
||||
/// So **the plate's BOTTOM-centre is pinned to the screen's BOTTOMLEFT plus the cursor's absolute
|
||||
/// position** — centred horizontally on the cursor and sitting directly above it. Four details are
|
||||
/// worth keeping because none is guessable from the name:
|
||||
///
|
||||
/// * The `relativeTo` is the screen root passed **raw**, not `+0x24` like every other anchor — the
|
||||
/// `CSimpleTop`'s `CLayoutFrame` base sits at offset 0. Ours is [`SCREEN`](super::SCREEN), which
|
||||
/// is the same object.
|
||||
/// * The **divide by the tooltip's own effective scale** is the "cursor box recip": an anchor
|
||||
/// offset is stored raw and multiplied by the child's scale at resolve time, so dividing here is
|
||||
/// what makes the result land at the cursor's *absolute* position. Ours does the same divide,
|
||||
/// against the same scale [`GetWidth`](super::object::eff_scale) reads.
|
||||
/// * **The `SetOwner`-time offsets `+0x3c4/+0x3c8` do not participate** — 12 reads of them, every
|
||||
/// one on a modes-0..5 arm of `0x52fe90`.
|
||||
/// * There is **no `ClearAllPoints` here** (earned zero over the function's complete call set): one
|
||||
/// anchor slot is overwritten, and `0x767c70`'s own no-op gate — same target, same
|
||||
/// `relativePoint`, both offsets within 2⁻²² — is what keeps a still cursor free.
|
||||
///
|
||||
/// It runs from the per-frame update pump, which a hidden tooltip is not in (`0x76ad9d`
|
||||
/// deregisters on hide), and in the **same frame** as the placement it produces: the layout drain
|
||||
/// `0x768ed0` runs at `0x765799`, right after the `+0x38` loop and before the draw. Hence its home
|
||||
/// here, at the top of the resolve, beside the auto-size pass.
|
||||
fn cursor_anchor(model: &mut Model, h: FrameHandle) {
|
||||
let scale = crate::script::object::eff_scale(model, h);
|
||||
let (cx, cy) = model.cursor_pos;
|
||||
let new = Anchor::new(
|
||||
Point::Bottom,
|
||||
super::SCREEN,
|
||||
Point::BottomLeft,
|
||||
cx / scale,
|
||||
cy / scale,
|
||||
);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// `0x767c70`'s change-detect, in our own terms: one slot, replaced only on a real change.
|
||||
let same =
|
||||
input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
if same {
|
||||
return;
|
||||
}
|
||||
let old_targets: Vec<u32> = input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors = vec![new];
|
||||
model.touch_layout_retarget_frame(h, &old_targets, &[super::SCREEN]);
|
||||
}
|
||||
|
||||
pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
|
||||
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
|
||||
@@ -686,6 +747,16 @@ pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
.filter(|h| model.frame_to_id.contains_key(h))
|
||||
.collect();
|
||||
for h in tips {
|
||||
// Mode 6 first, and **before the line gate below**: the reference's per-frame update is
|
||||
// not gated on content at all, only on the plate being in the pump's list — which is the
|
||||
// shown list. See [`cursor_anchor`].
|
||||
let cursor_mode = match model.arena.frame(h).map(|f| (&f.kind_state, f.shown)) {
|
||||
Some((KindState::Tooltip(t), shown)) => shown && t.anchor == TooltipAnchor::Cursor,
|
||||
_ => false,
|
||||
};
|
||||
if cursor_mode {
|
||||
cursor_anchor(model, h);
|
||||
}
|
||||
let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => (
|
||||
t.num_lines,
|
||||
|
||||
@@ -20,24 +20,35 @@ use super::{
|
||||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// SetOwner(owner, anchorType [, x, y]) — remember the owner, clear the content (the engine
|
||||
// fires OnTooltipCleared: a fresh owner never inherits the last hover's lines or money), and
|
||||
// point the plate per the anchor law. The six owner anchors are the documented 1.12 API set
|
||||
// (spec-faithful, same posture as StatusBar's contract; the corpus exercises RIGHT/LEFT/
|
||||
// BOTTOMRIGHT); ANCHOR_NONE leaves pointing to the caller (GameTooltip_SetDefaultAnchor),
|
||||
// ANCHOR_PRESERVE keeps the previous placement. The mode is stored on the tooltip and read back
|
||||
// by `GetAnchorType` below.
|
||||
// SetOwner(owner, anchorType [, x, y]) — the binding `0x5310d0` and the core it calls,
|
||||
// `0x52ffe0(owner_region, mode, dx, dy)`: SetAlpha(255) (`0x52fff4`), fade-disarm
|
||||
// (`0x530002`), `+0x314 = owner` (`0x53000c`), the mode (`0x530031`), the offsets, then the
|
||||
// anchor-apply `0x52fe90`. We clear the content too, because the engine fires
|
||||
// OnTooltipCleared: a fresh owner never inherits the last hover's lines or money.
|
||||
//
|
||||
// **The `anchorType`-omitted default here is ANCHOR_RIGHT and the reference's is ANCHOR_NONE.**
|
||||
// The reference's SetOwner core defaults `[+0x318]` to mode 7 = ANCHOR_NONE (`0x530012`, wow-re
|
||||
// `bag-portrait-and-appendtext.md` §5) — no `SetPoint` at all, the plate left wherever it was.
|
||||
// Two stock 1.12 files reach that leg (`QuestTimerFrame.xml:19`, `WorldStateFrame.xml:681`,
|
||||
// both `GameTooltip:SetOwner(this)`), and neither is on `benilla.toc` yet, so nothing today
|
||||
// takes it. It is left alone deliberately rather than quietly flipped: our ANCHOR_NONE arm
|
||||
// below DROPS the plate's anchors where the reference merely skips the SetPoint, so changing
|
||||
// the default would land those two hovers at the layout origin rather than "wherever it was" —
|
||||
// the two questions have to be settled together, and the second one is a visible-placement
|
||||
// change. Recorded here so the next reader sees a stated divergence, not an oversight.
|
||||
// **The omitted / unreadable / unrecognised anchorType is mode 0 = ANCHOR_LEFT, silently.**
|
||||
// `0x53120d` writes 0 into the binding's local mode *before any compare runs*; `0x531214`'s
|
||||
// `lua_isstring` gate jumps the whole `SStrCmpI` chain when arg 3 is absent, nil, boolean or a
|
||||
// table; the chain's last `jne` reaches the same join untouched; and `[0x531221, 0x53133a)`
|
||||
// contains no `luaL_error` (earned zero, positive-controlled against the five this function
|
||||
// *does* have in its owner-validation prologue). `0x5313c0` then hands that local to the core.
|
||||
//
|
||||
// The core's own `mov [+0x318],7` at `0x530012` is **not** that default: it is an
|
||||
// unconditional pre-store that `0x530031` overwrites with the argument, and it survives only
|
||||
// when the owner pointer is NULL — the un-own path `0x530a60` takes on every hide.
|
||||
//
|
||||
// **The anchor-apply clears, and only ANCHOR_PRESERVE escapes it** (`0x52fe90` read
|
||||
// contiguously): a NULL owner returns at `0x52fe9e`; **mode 8 returns at `0x52fead`, before
|
||||
// the clear**; then `0x52feb8` tests the caller's arg1, which `0x530037` always passes as
|
||||
// **1** (`0x53002d push 0x1`), so the mode-7 skip at `0x52feba` is dead on this path and
|
||||
// `0x52fec2 call 0x767ed0` (ClearAllPoints) runs for **every mode 0..7**; and only then does
|
||||
// `0x52fed0 ja` send 6 and 7 home while 0..5 take the SetPoint jump table `0x52ffbc`.
|
||||
//
|
||||
// So the three arms below are: 0..5 clear-and-point, **6 and 7 clear**, 8 nothing. Decision
|
||||
// 2176 replaces 2142's open thread 1, which had it backwards on both counts — it read the
|
||||
// core's pre-store as the Lua default and read "no SetPoint" as "no clear", and a wow-re §5
|
||||
// dispatched for the CURSOR mechanism refuted both at the bytes (`system/ui/scratch/`
|
||||
// `tooltip-cursor-anchor-law.md` §0.2/§0.3/§2).
|
||||
m.set(
|
||||
"SetOwner",
|
||||
lua.create_function(
|
||||
@@ -59,29 +70,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let owner_id = model.frame_id(owner_h);
|
||||
// The anchor mode is resolved ONCE, into the reference's own enum, and both
|
||||
// the placement below and `GetAnchorType`'s answer are taken from that one
|
||||
// value — so the plate and the getter can never tell different stories. An
|
||||
// unrecognised string (`ANCHOR_CURSOR` among them, which this engine cannot
|
||||
// honour — see [`TooltipAnchor::Cursor`]) warns and is recorded as the
|
||||
// ANCHOR_RIGHT it is actually placed by, not as the string it was handed.
|
||||
// value — so the plate and the getter can never tell different stories.
|
||||
//
|
||||
// Absent, unreadable and unrecognised all land on **ANCHOR_LEFT**, which is
|
||||
// the binding's zero-initialised local (`0x53120d`) surviving the
|
||||
// `lua_isstring` gate or the compare chain, with no raise. The warning on the
|
||||
// unrecognised leg is ours and stays: the reference is silent, but nothing
|
||||
// observable to Lua changes, and this exact warning is what made
|
||||
// `ANCHOR_CURSOR` visible as a real ninth mode nine corpus files ask for
|
||||
// rather than a typo (2142's open thread 2).
|
||||
let anchor = match anchor
|
||||
.as_deref()
|
||||
.map(str::to_ascii_uppercase)
|
||||
.as_deref()
|
||||
.unwrap_or("ANCHOR_RIGHT")
|
||||
.unwrap_or("")
|
||||
{
|
||||
"ANCHOR_RIGHT" => TooltipAnchor::Right,
|
||||
"ANCHOR_LEFT" => TooltipAnchor::Left,
|
||||
"ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight,
|
||||
"ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft,
|
||||
"ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight,
|
||||
"ANCHOR_RIGHT" => TooltipAnchor::Right,
|
||||
"ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft,
|
||||
"ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight,
|
||||
"ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft,
|
||||
"ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight,
|
||||
"ANCHOR_CURSOR" => TooltipAnchor::Cursor,
|
||||
"ANCHOR_NONE" => TooltipAnchor::None,
|
||||
"ANCHOR_PRESERVE" => TooltipAnchor::Preserve,
|
||||
"" => TooltipAnchor::Left,
|
||||
other => {
|
||||
model.record_warning(format!(
|
||||
"SetOwner: unknown anchor '{other}' (kept ANCHOR_RIGHT)"
|
||||
"SetOwner: unknown anchor '{other}' (mode 0, ANCHOR_LEFT)"
|
||||
));
|
||||
TooltipAnchor::Right
|
||||
TooltipAnchor::Left
|
||||
}
|
||||
};
|
||||
tip_mut(&mut model, h)?.anchor = anchor;
|
||||
@@ -96,58 +114,56 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
None
|
||||
}
|
||||
};
|
||||
match pts {
|
||||
Some((own, rel)) => {
|
||||
let new =
|
||||
Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0));
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// The no-op compare keeps the per-frame SetOwner idiom (the bag
|
||||
// hover's OnUpdate re-enter) from dirtying tier 1 by itself; the
|
||||
// content clear above already reports its own writes.
|
||||
let same = input.anchors.len() == 1
|
||||
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
if !same {
|
||||
// The plate re-points at the button under the cursor, which moves
|
||||
// an EDGE — structural under 1388, and therefore a whole-graph
|
||||
// derivation on every bag slot and every spellbook button a hover
|
||||
// sweep crossed. It names its node now (decision 1625): the old
|
||||
// and new target lists are both right here, so the cached graph's
|
||||
// edges are re-pointed instead of thrown away.
|
||||
let old_targets: Vec<u32> =
|
||||
input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors = vec![new];
|
||||
model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]);
|
||||
// **Modes 6 and 7 CLEAR and stop; mode 8 does neither.** `0x52fe90` reaches
|
||||
// `0x52fec2 call 0x767ed0` for every mode 0..7 — the mode-7 skip beside it is
|
||||
// gated on an arg1 that `SetOwner`'s core always passes as 1 — and mode 8
|
||||
// returned two compares earlier, at `0x52fead`, without touching anything.
|
||||
//
|
||||
// For ANCHOR_NONE that clear is what makes `GameTooltip_SetDefaultAnchor`
|
||||
// (`SetOwner(owner, "ANCHOR_NONE")` → `ClearAllPoints()` → `SetPoint(...)`,
|
||||
// the path of every action-bar hover) safe: a stale owner anchor cannot win
|
||||
// the frame the caller forgets to re-point. For ANCHOR_CURSOR it is the
|
||||
// opening move of a placement the per-frame update finishes
|
||||
// (`script::tooltip::cursor_anchor`).
|
||||
//
|
||||
// Dropping every anchor is a retarget to the EMPTY target set, and it names
|
||||
// its node like any other (decision 1625). It matters here more than
|
||||
// anywhere: left on the conservative touch, this line was a whole-graph
|
||||
// derivation on every action button the cursor crossed and on nothing else —
|
||||
// exactly the shape the director's recorder reported (decision 1630).
|
||||
if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) {
|
||||
let dropped = match model.layout_inputs.get_mut(&h) {
|
||||
Some(input) if !input.anchors.is_empty() => {
|
||||
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(old_targets) = dropped {
|
||||
let old_targets: Vec<u32> = old_targets;
|
||||
model.touch_layout_retarget_frame(h, &old_targets, &[]);
|
||||
}
|
||||
None if anchor == TooltipAnchor::None => {
|
||||
// The caller points it (ClearAllPoints+SetPoint) — drop ours now so a
|
||||
// stale owner anchor never wins the frame the caller forgets to.
|
||||
//
|
||||
// Dropping every anchor is a retarget to the EMPTY target set, and it
|
||||
// names its node like any other (decision 1625). It matters here more
|
||||
// than anywhere: this arm is `GameTooltip_SetDefaultAnchor`'s, which is
|
||||
// the path EVERY action-bar hover takes (stock `ActionButton_SetTooltip`, the
|
||||
// `UberTooltips` default; the stance, pet and bonus bars the same) —
|
||||
// while a bag slot anchors straight to its button and never comes
|
||||
// through here. So a conservative touch on this line was a whole-graph
|
||||
// derivation on every action button the cursor crossed, and on nothing
|
||||
// else, which is exactly the shape the director's recorder reported (decision 1630):
|
||||
// every derive frame owned by a MultiBar/BonusAction button.
|
||||
let dropped = match model.layout_inputs.get_mut(&h) {
|
||||
Some(input) if !input.anchors.is_empty() => {
|
||||
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(old_targets) = dropped {
|
||||
let old_targets: Vec<u32> = old_targets;
|
||||
model.touch_layout_retarget_frame(h, &old_targets, &[]);
|
||||
}
|
||||
}
|
||||
if let Some((own, rel)) = pts {
|
||||
let new =
|
||||
Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0));
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// The no-op compare keeps the per-frame SetOwner idiom (the bag
|
||||
// hover's OnUpdate re-enter) from dirtying tier 1 by itself; the
|
||||
// content clear above already reports its own writes.
|
||||
let same = input.anchors.len() == 1
|
||||
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
if !same {
|
||||
// The plate re-points at the button under the cursor, which moves
|
||||
// an EDGE — structural under 1388, and therefore a whole-graph
|
||||
// derivation on every bag slot and every spellbook button a hover
|
||||
// sweep crossed. It names its node now (decision 1625): the old
|
||||
// and new target lists are both right here, so the cached graph's
|
||||
// edges are re-pointed instead of thrown away.
|
||||
let old_targets: Vec<u32> =
|
||||
input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors = vec![new];
|
||||
model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]);
|
||||
}
|
||||
// ANCHOR_PRESERVE. `TooltipAnchor::Cursor` shares the arm for
|
||||
// exhaustiveness only — the string match above never produces it (it warns
|
||||
// and records ANCHOR_RIGHT instead), so nothing reaches here that way.
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fire_cleared(lua, h);
|
||||
|
||||
@@ -361,9 +361,9 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"WM = CreateFrame("Frame", "WM") WM:SetWidth(1024); WM:SetHeight(768) WM:SetPoint("CENTER")
|
||||
r#"WM = CreateFrame("Frame", "WM") WM:SetWidth(1024); WM:SetHeight(768) WM:SetPoint("CENTER", 0, 0)
|
||||
Detail = CreateFrame("Frame", "WorldMapDetailFrame", WM)
|
||||
Detail:SetWidth(1002); Detail:SetHeight(668) Detail:SetPoint("CENTER")"#,
|
||||
Detail:SetWidth(1002); Detail:SetHeight(668) Detail:SetPoint("CENTER", 0, 0)"#,
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
|
||||
@@ -259,19 +259,27 @@ impl KindState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tooltip's anchor mode — the reference's `+0x318`, which `SetOwner` writes and
|
||||
/// `GetAnchorType 0x5313e0` reads back through the 9-entry `{value, name}` table `0x531530`
|
||||
/// (wow-re `bag-portrait-and-appendtext.md` §5; the mode ids in
|
||||
/// `hover-hide-and-tooltip-owner-law.md` §4).
|
||||
/// A tooltip's anchor mode — the reference's `+0x318`, which `SetOwner 0x5310d0` writes and
|
||||
/// `GetAnchorType 0x5313e0` reads back through the 9-entry jump table `.rdata 0x531530`.
|
||||
///
|
||||
/// **All nine of the reference's modes are named here even though `SetOwner` cannot reach
|
||||
/// [`TooltipAnchor::Cursor`].** The variant is not decoration: mode 6 is the cursor-following
|
||||
/// tooltip, driven per frame by the class's own OnUpdate override `0x530b20` (re-anchor to
|
||||
/// `[root+0x1118/0x111c]`), and nine corpus files ask for it by name — `pfUI`'s tooltip, xpbar and
|
||||
/// chat modules, `pfQuest/browser.lua`, `TipBuddy`. Following the cursor is a mechanism this engine
|
||||
/// does not have yet, and 1203 says a verb is not stubbed to make a caller happy, so
|
||||
/// `tooltip::verbs`'s `SetOwner` still warns on the string and keeps its previous placement rather
|
||||
/// than silently pretending. Naming the variant is what keeps that gap visible instead of implicit.
|
||||
/// **The id order below is the jump table's, and it is NOT the setter's compare order** — a
|
||||
/// distinction that cost wow-re a contradiction between two of its own notes (`bag-portrait-and-
|
||||
/// appendtext.md` §5 had ids 2/3 and 4/5 swapped, because the setter compares `ANCHOR_BOTTOMRIGHT`
|
||||
/// third and stores it as id 3 while comparing `ANCHOR_BOTTOMLEFT` fourth and storing it as id 2;
|
||||
/// the getter's arm *bodies* sit in `.text` in that same compare order, so it reads as
|
||||
/// corroboration). Settled from three independent ends and fixed in place there:
|
||||
/// `system/ui/scratch/tooltip-cursor-anchor-law.md` §0/§0.1 (decision 2176).
|
||||
///
|
||||
/// **All nine modes are reachable.** `SetOwner`'s absent / non-string / unrecognised argument is
|
||||
/// mode **0** = [`TooltipAnchor::Left`], silently — the binding zero-initialises its local at
|
||||
/// `0x53120d` and neither the `lua_isstring` gate nor the compare chain's fall-through raises.
|
||||
///
|
||||
/// **What each mode does to the placement** (`0x52fe90`, read contiguously): a NULL owner returns
|
||||
/// at once; mode **8** returns at `0x52fead`, *before* the clear; every other mode reaches
|
||||
/// `0x52fec2 call 0x767ed0` (ClearAllPoints) — the mode-7 skip beside it is gated on an arg1
|
||||
/// `SetOwner`'s core always passes as 1 — and then modes **0..5** take the SetPoint jump table
|
||||
/// `0x52ffbc` while **6** and **7** stop there. So: 0..5 clear and point, 6 and 7 clear, 8 does
|
||||
/// neither.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TooltipAnchor {
|
||||
/// `ANCHOR_LEFT` — the plate's right edge on the owner's left.
|
||||
@@ -287,18 +295,28 @@ pub enum TooltipAnchor {
|
||||
TopLeft,
|
||||
/// `ANCHOR_TOPRIGHT`.
|
||||
TopRight,
|
||||
/// `ANCHOR_CURSOR` — mode 6, the plate re-anchored to the live cursor every frame. Reachable
|
||||
/// in the reference and NOT constructible here; see the type doc.
|
||||
/// `ANCHOR_CURSOR` — mode 6: the anchors are cleared, and then the plate is re-anchored to the
|
||||
/// live cursor **every frame** by the class's own update override `0x530b20`
|
||||
/// (`script::tooltip::cursor_anchor` carries the law). Nine corpus files ask for it — `pfUI`'s
|
||||
/// tooltip, xpbar and chat modules, `pfQuest/browser.lua`, `TipBuddy`.
|
||||
Cursor,
|
||||
/// `ANCHOR_NONE` — mode 7: the owner is recorded and no `SetPoint` is applied, leaving the
|
||||
/// `ANCHOR_NONE` — mode 7: the anchors are cleared and no `SetPoint` follows, leaving the
|
||||
/// caller to point the plate (`GameTooltip_SetDefaultAnchor` does exactly that on the next
|
||||
/// line). **The default**, because it is the mode the reference's own `SetOwner` core stores
|
||||
/// when the caller passes no anchor string (`0x530012`), and the only one of the nine that is
|
||||
/// true of a plate nothing has anchored yet.
|
||||
/// line, and that clear is what keeps a stale owner anchor from winning the frame a caller
|
||||
/// forgets to re-point).
|
||||
///
|
||||
/// **The default here**, which is a benilla choice rather than a read byte: the ctor
|
||||
/// `0x529240` does not write `+0x318` at all, so what a never-owned plate holds depends on the
|
||||
/// allocator, and `GetAnchorType` answers `"ANCHOR_NONE"` for anything out of range
|
||||
/// (`0x53146f ja` shares the id-7 arm). ANCHOR_NONE is therefore the answer that is right
|
||||
/// under the widest reading, and it is the one `pfUI/modules/tooltip.lua:97` compares against.
|
||||
/// It is **not** `SetOwner`'s omitted-argument default, which is mode 0 — see the type doc.
|
||||
#[default]
|
||||
None,
|
||||
/// `ANCHOR_PRESERVE` — mode 8: the owner is recorded and the previous placement is kept
|
||||
/// (`0x52fe90` skips the anchor-apply). Stored like any other mode, so `GetAnchorType` answers
|
||||
/// *including its anchors* — `0x52fe90` returns at `0x52fead`, before the ClearAllPoints that
|
||||
/// every other mode reaches. That clear-vs-keep split is the entire difference between this
|
||||
/// and [`TooltipAnchor::None`]. Stored like any other mode, so `GetAnchorType` answers
|
||||
/// `"ANCHOR_PRESERVE"` after one — the reference does not resolve it back to what it preserved.
|
||||
Preserve,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user