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:
Sam
2026-09-11 21:03:49 +07:00
co-authored by Claude Fable 5.1
parent a61b9159ac
commit e72016630c
53 changed files with 1978 additions and 541 deletions
@@ -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);