Texture and FontString stop answering each other's methods

- the one shared region table splits into the client's own maps: a Texture no longer answers SetText, a FontString no longer answers SetTexture, and feature-detecting addons see the true surface
- CreateTitleRegion lands whole — object and drag — so an options window that is its own drag handle actually moves
- font-object names resolve whatever their case, and the CursorUpdate/CursorOnUpdate pair arrives for the container-item templates
- GetStringHeight is pruned (1.12 has no such method) and SetPortraitToTexture moves to the global it really is

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:03:34 +07:00
co-authored by Claude Fable 5
parent 56d515a374
commit 84f93c47de
32 changed files with 1109 additions and 80 deletions
@@ -142,6 +142,86 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
},
)?,
)?;
// ── The title region: Frame:CreateTitleRegion() / GetTitleRegion() ──────────────────────────
//
// wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q6 (`0x773910` / `0x773820`). Four
// details, each one a coin-flip a reimplementation loses:
//
// · **It reads NO argument at all** — nothing in `0x773910`-`0x773a1f` touches Lua index 2. So
// `CustomNameplates/options.lua:73`'s `CreateTitleRegion(optionsFrame)` is harmless and
// identical to the no-arg call, rather than an error or a differently-parented region.
// · **It is IDEMPOTENT, destructively.** One region per frame (`CSimpleFrame+0xA8`): a second
// call runs ClearAllPoints on the existing one and returns THE SAME OBJECT — so calling it
// on an XML-declared `<TitleRegion>` silently wipes that region's anchors. Returning a
// fresh region instead would leave the first one hit-testing forever.
// · **A fresh one has NO anchors** and does nothing until `SetPoint`/`SetAllPoints`. Both
// corpus consumers immediately call `SetAllPoints`, which is the whole-window drag idiom.
// · **`GetTitleRegion` answers 1 value (nil)** when there is none — note the asymmetry with
// `GetBackdrop`, which answers 0 values. Q6 flags it explicitly; both converged
// independently there, so both are reproduced here.
// **ONE NAMED DIVERGENCE, because it is a superset and 1189 is what a superset costs.** Q6 says
// the object answers *exactly* the 19 Region methods — no Show/Hide, no scripts, no textures.
// Ours answers the whole shared region table, because Texture/FontString/Title use one
// metatable here, so `titleRegion:SetTexture(…)` is accepted where the reference would raise
// `attempt to call method`. It is inert rather than wrong — a title region never draws (the
// extract skips its kind outright), so a texture or a Hide set on one changes nothing — but an
// addon that FEATURE-DETECTS would see a method the real client does not have. Splitting the
// metatable is the fix; it is not free, and nothing in the corpus asks, so this is recorded
// rather than hidden.
m.set(
"CreateTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let existing = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(owner).and_then(|f| f.title_region)
};
if let Some(rh) = existing {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
}
model.region_id(rh)
};
return region_wrapper(lua, id);
}
let wrapper = create_region(lua, &this, RegionKind::Title, None, None)?;
let id = decode_id(&wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let rh = *model
.id_to_region
.get(&id)
.expect("the region we just created is registered");
if let Some(f) = model.arena.frame_mut(owner) {
f.title_region = Some(rh);
}
Ok(wrapper)
})?,
)?;
m.set(
"GetTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let found = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.arena
.frame(owner)
.and_then(|f| f.title_region)
.map(|rh| model.region_id(rh))
};
match found {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
// ONE value, and it is nil — not zero values. See the `GetBackdrop` asymmetry above.
None => Ok(Value::Nil),
}
})?,
)?;
m.set(
"CreateFontString",
lua.create_function(
@@ -101,6 +101,15 @@ pub(crate) struct FrameMove {
frame: FrameHandle,
/// The cursor position at the last pump (`root+0xd08`/`+0xd0c`), UI px, y-up.
sample: (f32, f32),
/// Whether releasing the mouse ends this move on its own.
///
/// **The reference's drag MODE, reduced to the one bit that is observable from Lua.** The
/// mouse-up handler `0x766420` auto-cancels modes 1 (modifier-drag) and 2 (title region) and
/// leaves mode 3 (`StartMoving`) running until `StopMovingOrSizing`
/// (wow-re `widget-api-batch-benilla.md` Q6). So a title drag ends on release while a scripted
/// one outlives the button — which is exactly the distinction this module's own doc already
/// records for the drag/move split.
pub(crate) auto_stop: bool,
}
/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump.
@@ -337,10 +346,41 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
// Lua `StartMoving` is mode 3: it survives the button and ends only at
// `StopMovingOrSizing`.
auto_stop: false,
});
Ok(())
}
/// Begin a **title-region** move — mode 2, the drag a mouse-down inside `frame:GetTitleRegion()`
/// starts (wow-re Q6, `0x7662c0` → `0x765320(frame, mode=2, …)` → `0x7652b0`).
///
/// [`start_moving`]'s body **minus the movable gate**, and that omission is the carved part rather
/// than a shortcut: the movable bit is `frame+0xb4 & 0x100`, tested by `StartMoving` (`0x77678b`,
/// else `"Frame %s is not movable"`) and by the modifier-drag path — and **not read anywhere** on
/// `0x7662c0`→`0x765320`→`0x7652b0`→`0x768430`. Q6 marks the no-gate reading VERIFIED and the
/// observable claim INFERRED, because both FrameXML users happen to be `movable="true"`; a title
/// region on a non-movable frame is the case that would tell them apart, and it drags here.
///
/// Returns whether a move actually started — `false` when one was already in flight, which is
/// `0x7767e8`'s `root+0xcfc != 0` guard and the same refusal [`start_moving`] makes.
pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
if model.moving.is_some() {
return false;
}
super::toplevel::raise(model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
auto_stop: true,
});
true
}
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor