benilla: initial public release

A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-24 14:27:01 +07:00
co-authored by Claude Fable 5
commit 2ad0043523
778 changed files with 295134 additions and 0 deletions
@@ -0,0 +1,189 @@
//! Frame method-table cluster: event registration + script handlers (`RegisterEvent`/
//! `UnregisterEvent`/`SetScript`/`GetScript`), the drag-gesture registration (`RegisterForDrag`,
//! decision 0216 §3), and region creation (`CreateTexture`/`CreateFontString`). Split out of
//! [`super`] purely for size — see its module doc for the shared id/handle plumbing and
//! method-table wiring.
use std::collections::HashSet;
use mlua::{Function, Lua, MultiValue, Table, Value};
use crate::script::region::region_wrapper;
use crate::script::{Model, RegionData, REG_SCRIPTS, SCRIPT_KINDS};
use crate::widget::RegionKind;
use super::{decode_id, draw_layer_from_str, frame_handle_of, publish_global};
/// Populate `m`'s event/script and region-creation methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Events + scripts
m.set(
"RegisterEvent",
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Ordered listener list (the client's SignalEvent walks a LIST — order is law);
// re-register keeps the original position (no duplicate fire).
let list = model.event_to_frames.entry(event.clone()).or_default();
if !list.contains(&h) {
list.push(h);
}
model.frame_events.entry(h).or_default().insert(event);
Ok(())
})?,
)?;
m.set(
"UnregisterEvent",
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(list) = model.event_to_frames.get_mut(&event) {
list.retain(|x| x != &h);
}
if let Some(set) = model.frame_events.get_mut(&h) {
set.remove(&event);
}
Ok(())
})?,
)?;
m.set(
"SetScript",
lua.create_function(
|lua, (this, name, func): (Table, String, Option<Function>)| {
set_script(lua, &this, &name, func)
},
)?,
)?;
m.set(
"GetScript",
lua.create_function(|lua, (this, name): (Table, String)| get_script(lua, &this, &name))?,
)?;
// RegisterForDrag(...varargs of button names) — the drag-gesture twin of `RegisterForClicks`
// (decision 0216 §3), but on the SHARED table: any Frame can be a drag source, not just a
// Button. Replace-the-set semantics (empty varargs clears); `crate::script::cursor`'s
// arm/start/release path consults the set case-insensitively (the `RegisterForClicks`
// precedent). Pruning on frame destroy: see [`Model::drag_registered`]'s doc — nothing in
// this engine destroys a frame yet, so this map is in the same boat as `scripts`/
// `frame_events`.
m.set(
"RegisterForDrag",
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let h = frame_handle_of(lua, &this)?;
let mut set = HashSet::new();
for v in args.iter() {
if let Value::String(s) = v {
set.insert(s.to_str()?.to_string());
}
}
let mut model = lua.app_data_mut::<Model>().expect("model");
model.drag_registered.insert(h, set);
Ok(())
})?,
)?;
// Regions
m.set(
"CreateTexture",
lua.create_function(
|lua, (this, name, layer): (Table, Option<String>, Option<String>)| {
create_region(lua, &this, RegionKind::Texture, name, layer)
},
)?,
)?;
m.set(
"CreateFontString",
lua.create_function(
|lua, (this, name, layer): (Table, Option<String>, Option<String>)| {
create_region(lua, &this, RegionKind::FontString, name, layer)
},
)?,
)?;
Ok(())
}
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
.iter()
.copied()
.find(|&k| k.eq_ignore_ascii_case(name))
.ok_or_else(|| mlua::Error::runtime(format!("SetScript: unsupported script '{name}'")))?;
let h = frame_handle_of(lua, this)?;
let id = lua.app_data_mut::<Model>().expect("model").frame_id(h);
// Store the closure Lua-side (REG_SCRIPTS[id][kind]); update the Rust presence mirror.
let scripts: Table = lua.named_registry_value(REG_SCRIPTS)?;
let per: Table = match scripts.get::<Value>(id)? {
Value::Table(t) => t,
_ => {
let t = lua.create_table()?;
scripts.set(id, t.clone())?;
t
}
};
match func {
Some(f) => {
per.set(kind, f)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.scripts.entry(h).or_default().insert(kind);
}
None => {
per.set(kind, Value::Nil)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(set) = model.scripts.get_mut(&h) {
set.remove(&kind);
}
}
}
Ok(())
}
fn get_script(lua: &Lua, this: &Table, name: &str) -> mlua::Result<Value> {
let kind = match SCRIPT_KINDS
.iter()
.copied()
.find(|&k| k.eq_ignore_ascii_case(name))
{
Some(k) => k,
None => return Ok(Value::Nil),
};
let id = decode_id(this)?;
let scripts: Table = lua.named_registry_value(REG_SCRIPTS)?;
match scripts.get::<Value>(id)? {
Value::Table(t) => t.get::<Value>(kind),
_ => Ok(Value::Nil),
}
}
fn create_region(
lua: &Lua,
this: &Table,
kind: RegionKind,
name: Option<String>,
layer: Option<String>,
) -> mlua::Result<Table> {
let owner = frame_handle_of(lua, this)?;
let dl = layer
.as_deref()
.and_then(draw_layer_from_str)
.unwrap_or_default();
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let rh = model
.arena
.create_region(owner, kind, dl, 0)
.ok_or_else(|| mlua::Error::runtime("CreateTexture/FontString: dead owner frame"))?;
model.region_data.insert(rh, RegionData::default());
model.region_id(rh)
};
let wrapper = region_wrapper(lua, id)?;
if let Some(name) = name {
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`).
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_names.entry(name).or_insert(id);
}
Ok(wrapper)
}
@@ -0,0 +1,420 @@
//! Frame method-table cluster: Show/Hide/visibility, identity/hierarchy (`GetName`/`GetParent`/
//! `SetParent`), strata/level/scale/alpha, the Backdrop mechanism (`SetBackdrop`/`SetBackdropColor`/
//! `SetBackdropBorderColor`), and mouse-enable. Split out of [`super`] purely for size — the shared
//! id/handle plumbing, `CreateFrame`, and the method-table wiring stay there; this module's
//! [`install`] just populates its share of the one shared method table.
use mlua::{Lua, Table, Value};
use crate::order::Strata;
use crate::script::{event, Backdrop, Insets, Model};
use super::{decode_id, frame_handle_of, frame_wrapper, strata_from_str};
/// Populate `m`'s visibility/hierarchy/strata/backdrop/mouse methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Show / Hide / visibility
m.set(
"Show",
lua.create_function(|lua, this: Table| set_shown(lua, &this, true))?,
)?;
m.set(
"Hide",
lua.create_function(|lua, this: Table| set_shown(lua, &this, false))?,
)?;
// SetShown(bool) — the live API's branchless Show/Hide (a consensus call across the 0068
// target addons; Lua truthiness, so SetShown(nil) hides).
m.set(
"SetShown",
lua.create_function(|lua, (this, shown): (Table, Value)| {
let show = !matches!(shown, Value::Nil | Value::Boolean(false));
set_shown(lua, &this, show)
})?,
)?;
m.set(
"IsShown",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.shown).unwrap_or(false))
})?,
)?;
m.set(
"IsVisible",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.arena
.frame(h)
.map(|f| f.effective_visible)
.unwrap_or(false))
})?,
)?;
// Identity / hierarchy
m.set(
"GetName",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(h).and_then(|f| f.name.clone())
};
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetID/SetID — the app-meaning-free numeric label (XML `id=`, the client's `+0xb4`): a
// dropdown row's list position, a tab index. Default 0 (see `Frame::wow_id`).
m.set(
"GetID",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.wow_id).unwrap_or(0))
})?,
)?;
m.set(
"SetID",
lua.create_function(|lua, (this, id): (Table, i64)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(f) = model.arena.frame_mut(h) {
f.wow_id = id;
}
Ok(())
})?,
)?;
m.set(
"GetParent",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let parent_id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.arena
.frame(h)
.and_then(|f| f.parent)
.map(|p| model.frame_id(p))
};
match parent_id {
Some(pid) => Ok(Value::Table(frame_wrapper(lua, pid)?)),
None => Ok(Value::Nil),
}
})?,
)?;
m.set(
"SetParent",
lua.create_function(|lua, (this, parent): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let new_parent = {
let model = lua.app_data_ref::<Model>().expect("model");
match &parent {
Value::Table(t) => decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied()),
Value::String(s) => {
s.to_str().ok().and_then(|n| model.arena.lookup(n.as_ref()))
}
_ => None,
}
};
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.set_parent(h, new_parent)
};
event::fire_visibility_changes(lua, changed);
Ok(())
})?,
)?;
// Strata / level / scale / alpha
m.set(
"SetFrameStrata",
lua.create_function(|lua, (this, strata): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let s = strata_from_str(&strata)
.ok_or_else(|| mlua::Error::runtime(format!("unknown frameStrata '{strata}'")))?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_frame_strata(h, s);
Ok(())
})?,
)?;
m.set(
"GetFrameStrata",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let s = model.arena.frame(h).map(|f| f.strata).unwrap_or_default();
Ok(strata_name(s).to_string())
})?,
)?;
m.set(
"SetFrameLevel",
lua.create_function(|lua, (this, level): (Table, i64)| {
let h = frame_handle_of(lua, &this)?;
let lvl = level.clamp(0, i64::from(u16::MAX)) as u16;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_frame_level(h, lvl, true);
Ok(())
})?,
)?;
m.set(
"GetFrameLevel",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(i64::from(
model.arena.frame(h).map(|f| f.level).unwrap_or(0),
))
})?,
)?;
m.set(
"SetScale",
lua.create_function(|lua, (this, scale): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_scale(h, scale);
Ok(())
})?,
)?;
m.set(
"GetScale",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.scale).unwrap_or(1.0))
})?,
)?;
m.set(
"SetAlpha",
lua.create_function(|lua, (this, alpha): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
// The 1.12 API clamps to 0..1 — ref Lua leans on it (fade code that passes a 0..255
// alpha stays fully opaque until the value falls below 1/255).
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_alpha(h, alpha.clamp(0.0, 1.0));
Ok(())
})?,
)?;
m.set(
"GetAlpha",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.alpha).unwrap_or(1.0))
})?,
)?;
// Backdrop (backdrop-mechanism.md): SetBackdrop(table|nil) installs (or, with nil, tears down)
// the frame's tiled bg + 8-piece border plate. The two color setters tint the bg / all 8 border
// pieces (never the reverse — spec §4). The Lua-table SetBackdrop defaults both colors to WHITE
// (the ctor), so a caller must SetBackdropColor after to tint (the tooltip's OnLoad does).
m.set(
"SetBackdrop",
lua.create_function(|lua, (this, arg): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let bd = match arg {
Value::Nil => None,
Value::Table(t) => Some(backdrop_from_table(&t)?),
other => {
return Err(mlua::Error::runtime(format!(
"SetBackdrop: expected a table or nil, got {}",
other.type_name()
)))
}
};
let mut model = lua.app_data_mut::<Model>().expect("model");
match bd {
Some(b) => {
model.backdrops.insert(h, b);
}
None => {
model.backdrops.remove(&h);
}
}
Ok(())
})?,
)?;
m.set(
"SetBackdropColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = [r, g, b, a.unwrap_or(1.0)];
}
Ok(())
},
)?,
)?;
m.set(
"SetBackdropBorderColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = [r, g, b, a.unwrap_or(1.0)];
}
Ok(())
},
)?,
)?;
m.set(
"GetBackdropColor",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
Some(bd) => Ok((
bd.bg_color[0],
bd.bg_color[1],
bd.bg_color[2],
bd.bg_color[3],
)),
None => Ok((1.0, 1.0, 1.0, 1.0)),
}
})?,
)?;
m.set(
"GetBackdropBorderColor",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
Some(bd) => Ok((
bd.border_color[0],
bd.border_color[1],
bd.border_color[2],
bd.border_color[3],
)),
None => Ok((1.0, 1.0, 1.0, 1.0)),
}
})?,
)?;
// Mouse interaction (EnableMouse gates hit-testing; keyboard focus is out of scope)
m.set(
"EnableMouse",
lua.create_function(|lua, (this, enable): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_mouse_enabled(h, enable);
Ok(())
})?,
)?;
m.set(
"IsMouseEnabled",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_mouse_enabled(h))
})?,
)?;
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit4 — layout.md): the layout resolve
// keeps the frame's assembled rect inside the window, size preserved. GameTooltip frames
// default true by construction (widget::Frame::clamped_to_screen — decision 0352).
m.set(
"SetClampedToScreen",
lua.create_function(|lua, (this, clamp): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_clamped_to_screen(h, clamp);
Ok(())
})?,
)?;
m.set(
"IsClampedToScreen",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_clamped_to_screen(h))
})?,
)?;
Ok(())
}
/// Parse a Lua `SetBackdrop` table into a [`Backdrop`] (backdrop-mechanism.md §1). The keys read —
/// exactly the compiled reader's set — are `bgFile`/`edgeFile` (strings), `tile` (boolean, default
/// false), `tileSize`/`edgeSize` (numbers; a missing number leaves the ctor default: tileSize 0,
/// edgeSize 32), and `insets{left,right,top,bottom}` (numbers, each default 0). A non-string file or
/// non-number size is treated as absent (the client's per-key type gate). Colors stay the ctor
/// white — SetBackdrop never reads a color key; the color setters do that.
fn backdrop_from_table(t: &Table) -> mlua::Result<Backdrop> {
let mut bd = Backdrop::default();
if let Ok(Value::String(s)) = t.get::<Value>("bgFile") {
bd.bg_file = Some(s.to_str()?.to_string());
}
if let Ok(Value::String(s)) = t.get::<Value>("edgeFile") {
bd.edge_file = Some(s.to_str()?.to_string());
}
// `tile` — Lua truthiness (nil/false ⇒ false; a missing key reads as Nil).
bd.tile = !matches!(
t.get::<Value>("tile").unwrap_or(Value::Nil),
Value::Nil | Value::Boolean(false)
);
if let Ok(Value::Number(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
}
if let Ok(Value::Number(n)) = t.get::<Value>("edgeSize") {
bd.edge_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("edgeSize") {
bd.edge_size = n as f32;
}
if let Ok(Value::Table(ins)) = t.get::<Value>("insets") {
let num = |k: &str| -> f32 {
match ins.get::<Value>(k) {
Ok(Value::Number(n)) => n as f32,
Ok(Value::Integer(n)) => n as f32,
_ => 0.0,
}
};
bd.insets = Insets {
left: num("left"),
right: num("right"),
top: num("top"),
bottom: num("bottom"),
};
}
Ok(bd)
}
fn strata_name(s: Strata) -> &'static str {
match s {
Strata::World => "WORLD",
Strata::Background => "BACKGROUND",
Strata::Low => "LOW",
Strata::Medium => "MEDIUM",
Strata::High => "HIGH",
Strata::Dialog => "DIALOG",
Strata::Fullscreen => "FULLSCREEN",
Strata::FullscreenDialog => "FULLSCREEN_DIALOG",
Strata::Tooltip => "TOOLTIP",
Strata::Blizzard => "BLIZZARD",
}
}
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
Ok(())
}
@@ -0,0 +1,357 @@
//! Frame method-table cluster: anchoring and size — `SetPoint`/`ClearAllPoints`/`GetPoint`/
//! `SetAllPoints`/`SetWidth`/`SetHeight`/`SetSize`/`GetWidth`/`GetHeight` and the resolved-edge
//! readers (`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 crate::layout::{Anchor, Point};
use crate::script::{Model, SCREEN};
use crate::widget::FrameHandle;
use super::{as_f32, decode_id, frame_handle_of, frame_wrapper, point_from_str, point_name};
/// Populate `m`'s layout (anchor/size) methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Layout: SetPoint / ClearAllPoints / SetSize / 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])
},
)?,
)?;
m.set(
"ClearAllPoints",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(input) = model.layout_inputs.get_mut(&h) {
input.anchors.clear();
}
Ok(())
})?,
)?;
// GetPoint([n]) → point, relativeTo, relativePoint, xOfs, yOfs — the n-th (1-based, default
// first) anchor. relativeTo is nil when the target is the screen root (the client returns
// UIParent there; benilla has no UIParent wrapper yet — stated, a consensus-list call).
m.set(
"GetPoint",
lua.create_function(|lua, (this, n): (Table, Option<i64>)| {
let h = frame_handle_of(lua, &this)?;
let anchor = {
let model = lua.app_data_ref::<Model>().expect("model");
let idx = (n.unwrap_or(1).max(1) - 1) as usize;
model
.layout_inputs
.get(&h)
.and_then(|i| i.anchors.get(idx))
.cloned()
};
let Some(a) = anchor else {
return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil));
};
let rel = if a.relative_to == SCREEN {
Value::Nil
} else {
Value::Table(frame_wrapper(lua, a.relative_to)?)
};
Ok((
Value::String(lua.create_string(point_name(a.point))?),
rel,
Value::String(lua.create_string(point_name(a.relative_point))?),
Value::Number(f64::from(a.x_off)),
Value::Number(f64::from(a.y_off)),
))
})?,
)?;
// SetAllPoints([relativeTo]) — pin TOPLEFT+BOTTOMRIGHT to the target (default: the parent),
// the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path).
m.set(
"SetAllPoints",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
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(s) => {
// Frames first, then the region-name registry: the real XML anchors frames to
// REGIONS too (gossip option rows → the greeting FontString) — resolve() binds
// region targets in its second round.
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| default_parent_id(&mut model, h))
}
_ => default_parent_id(&mut model, h),
};
let input = model.layout_inputs.entry(h).or_default();
input.anchors.clear();
input.anchors.push(Anchor::new(
Point::TopLeft,
rel_id,
Point::TopLeft,
0.0,
0.0,
));
input.anchors.push(Anchor::new(
Point::BottomRight,
rel_id,
Point::BottomRight,
0.0,
0.0,
));
Ok(())
})?,
)?;
m.set(
"SetWidth",
lua.create_function(|lua, (this, w): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.layout_inputs.entry(h).or_default().width = w;
Ok(())
})?,
)?;
m.set(
"SetHeight",
lua.create_function(|lua, (this, ht): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.layout_inputs.entry(h).or_default().height = ht;
Ok(())
})?,
)?;
m.set(
"SetSize",
lua.create_function(|lua, (this, w, ht): (Table, f32, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let input = model.layout_inputs.entry(h).or_default();
input.width = w;
input.height = ht;
Ok(())
})?,
)?;
m.set(
"GetWidth",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, true))
})?,
)?;
m.set(
"GetHeight",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, false))
})?,
)?;
// GetCenter() → centerX, centerY — the resolved rect's center in LOCAL units (y-up; screen ÷
// the frame's effective scale — the client's convention: coordinate getters report the frame's
// own scaled space, and callers divide GetCursorPosition (screen px) by GetEffectiveScale to
// meet them there; the ref world map's hover math does exactly that). nil pair before the
// first resolve, like the edge readers.
m.set(
"GetCenter",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(match model.resolved.get(&h) {
Some(r) => (
Value::Number(f64::from((r.left + r.right) * 0.5 * inv)),
Value::Number(f64::from((r.bottom + r.top) * 0.5 * inv)),
),
None => (Value::Nil, Value::Nil),
})
})?,
)?;
// GetEffectiveScale() — the frame's real effective scale (parentScale · ownScale, the arena's
// propagated product). benilla has no uiScale CVar, so the root factor is 1; a SetScale'd
// subtree (the windowed world map) reports its true factor, and the reference's
// `GetCursorPosition()/GetEffectiveScale()` transcriptions convert screen→local correctly.
m.set(
"GetEffectiveScale",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(f64::from(eff_scale(&model, h)))
})?,
)?;
// GetLeft/GetRight/GetTop/GetBottom — the frame's RESOLVED edges in LOCAL units (y-up, so
// GetBottom is height-from-screen-bottom, the era semantics; `GetRect 0x768320`'s Lua faces,
// layout.rs's module doc; screen ÷ effective scale like GetCenter). `nil` before the first
// resolve — callers treat that as "not laid out yet" (the ref's own FauxScrollFrame code
// nil-checks these too).
for (name, pick) in [
("GetLeft", 0u8),
("GetRight", 1u8),
("GetTop", 2u8),
("GetBottom", 3u8),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(model.resolved.get(&h).map(|r| {
inv * match pick {
0 => r.left,
1 => r.right,
2 => r.top,
_ => r.bottom,
}
}))
})?,
)?;
}
Ok(())
}
/// The frame's effective scale, ε-guarded (a zero would poison the local-unit division).
fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
let s = model
.arena
.frame(h)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
if s.abs() < 1e-6 {
1.0
} else {
s
}
}
/// `GetWidth`/`GetHeight`: the resolved rect's span in LOCAL units (screen ÷ effective scale — the
/// client returns the value as authored, and `SetWidth(w)` on a scaled frame resolves to `w·scale`
/// screen px) if `resolve` has produced one, else the explicit size the frame was given
/// (`SetWidth`/`SetHeight`/`SetSize`, already local) — matching the client's "0 = derive".
fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 {
if let Some(r) = model.resolved.get(&h) {
let span = if width { r.width() } else { r.height() };
return span / eff_scale(model, h);
}
model
.layout_inputs
.get(&h)
.map(|i| if width { i.width } else { i.height })
.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}'")))?;
let h = frame_handle_of(lua, this)?;
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(s)) => {
cursor = 1;
// Frames first, then regions by name (see SetAllPoints above — same rationale).
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| {
// The parent fallback is the client's behavior, but a *named* target that
// doesn't resolve is almost always a bug (a typo, or an XML forward reference —
// anchors resolve at SetPoint time, so a target must be declared before its
// dependents). Say so instead of silently misdirecting the anchor: 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.warnings.push(format!(
"SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent",
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
));
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 input = model.layout_inputs.entry(h).or_default();
input.anchors.retain(|a| a.point != point);
input
.anchors
.push(Anchor::new(point, rel_to_id, rel_point, x, y));
Ok(())
}
/// The default `relativeTo` id for a `SetPoint` with no explicit target: the frame's parent id, or
/// [`SCREEN`] if it is top-level (the client anchors top-level frames to `UIParent`/the screen root).
fn default_parent_id(model: &mut Model, h: FrameHandle) -> u32 {
match model.arena.frame(h).and_then(|f| f.parent) {
Some(p) => model.frame_id(p),
None => SCREEN,
}
}