- The UI stopped paying to discover that nothing changed. Layout used to hash ~1,900 frames' worth of inputs every frame just to decide "quiet"; it now tracks a mutation epoch and answers with one integer compare, and a settled frame reuses last frame's rendered quads instead of re-rasterizing every glyph. ~0.9 ms off every idle frame — with a verify mode, forced on across all 489 UI tests, that re-runs every skipped resolve in full and asserts it would have produced identical results. - Across this run of snapshots, the one number everything is graded by: CPU work per frame at the crowded dungeon spot went from ~29 ms to ~16.5 ms. - A lighting question got the full treatment and an honest answer. Units briefly took a different day/night lighting law; it read wrong on screen the same evening, and a build-by-build hunt a full week back confirmed the world's look never regressed in the first place — the change is reverted, nothing about the look changes in this snapshot, and the question stays parked until the one remaining unknown in the reference client is pinned. - The character pane's model booth grew a headless eye — a dump of exactly what it draws, no live session needed — built for that hunt, kept for the next one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
849 lines
36 KiB
Rust
849 lines
36 KiB
Rust
//! The **region** side of the object model (RF-0023's distinct-tag leaves): the `Texture`/
|
|
//! `FontString` wrapper cache, their shared metatable, and the region method surface. Split from
|
|
//! [`super::object`] (which keeps the frame side + `CreateFrame`) so each grows along its own
|
|
//! axis — frames grow per-kind method tables ([`super::statusbar`], [`super::button`]), regions
|
|
//! grow paint/coords methods here.
|
|
|
|
use mlua::{Lua, Table, Value};
|
|
|
|
use super::object::{
|
|
anchor_bits_eq, as_f32, decode_id, draw_layer_from_str, id_to_lud, point_from_str,
|
|
};
|
|
use super::{
|
|
JustifyH, JustifyV, Model, Outline, TexCoords, REG_REGION_META, REG_REGION_METHODS,
|
|
REG_WRAPPERS, SCREEN,
|
|
};
|
|
use crate::layout::{Anchor, Point};
|
|
use crate::widget::RegionHandle;
|
|
|
|
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
|
pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result<RegionHandle> {
|
|
let id = decode_id(this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model
|
|
.id_to_region
|
|
.get(&id)
|
|
.copied()
|
|
.ok_or_else(|| mlua::Error::runtime("stale or invalid region handle"))
|
|
}
|
|
|
|
/// Get-or-create the wrapper table for a region id (distinct metatable — the region "tag").
|
|
pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
|
|
let wrappers: Table = lua.named_registry_value(REG_WRAPPERS)?;
|
|
if let Value::Table(t) = wrappers.get::<Value>(id)? {
|
|
return Ok(t);
|
|
}
|
|
let t = lua.create_table()?;
|
|
t.raw_set(0, Value::LightUserData(id_to_lud(id)))?;
|
|
let meta: Table = lua.named_registry_value(REG_REGION_META)?;
|
|
t.set_metatable(Some(meta))?;
|
|
wrappers.set(id, t.clone())?;
|
|
Ok(t)
|
|
}
|
|
|
|
/// Install the region method table + the shared region metatable.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
install_region_methods(lua)?;
|
|
|
|
// SetPortraitTexture(textureRegion, unit) — the live API's **global** (ref-UnitFrame.lua:
|
|
// `SetPortraitTexture(this.portrait, this.unit)`), distinct from the region-method
|
|
// `SetPortraitToTexture(path)` icon crop. It binds a Texture region to a unit token; the app
|
|
// renders that unit's model off-screen and feeds the bake back through the region's
|
|
// [`super::QuadContent::Texture::portrait_unit`], with `circular` marking the round stencil
|
|
// (the bake is square with an opaque backdrop; the frame-ring portraits cut the inscribed
|
|
// circle, exactly what the app's quad shader does with the flag). `texture`/`color` drop —
|
|
// the bake replaces them. A later `SetTexture`/`SetPortraitToTexture` clears the binding.
|
|
lua.globals().set(
|
|
"SetPortraitTexture",
|
|
lua.create_function(|lua, (region, unit): (Table, String)| {
|
|
let rh = region_handle_of(lua, ®ion)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.portrait_unit = Some(unit);
|
|
data.texture = None;
|
|
data.color = None;
|
|
data.circular = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// BenillaSetBoothTexture(textureRegion, slotToken) — the **square** twin of
|
|
// SetPortraitTexture (decision 0208 §5): the same `portrait_unit` booth-image binding
|
|
// WITHOUT the circular mask, for the paper doll's rectangular model pane (its texture region
|
|
// samples the booth's body bake edge to edge — no frame ring to mask for). Benilla-named:
|
|
// the real client's pane is a live 3D `<PlayerModel>`; ours is the doctrine-consistent
|
|
// still (0105/0118), so the binding is ours, not the live API's.
|
|
lua.globals().set(
|
|
"BenillaSetBoothTexture",
|
|
lua.create_function(|lua, (region, token): (Table, String)| {
|
|
let rh = region_handle_of(lua, ®ion)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.portrait_unit = Some(token);
|
|
data.texture = None;
|
|
data.color = None;
|
|
data.circular = false;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
let region_meta = lua.create_table()?;
|
|
let region_index = lua.create_function(|lua, (_this, key): (Table, Value)| {
|
|
let methods: Table = lua.named_registry_value(REG_REGION_METHODS)?;
|
|
methods.get::<Value>(key)
|
|
})?;
|
|
region_meta.set("__index", region_index)?;
|
|
lua.set_named_registry_value(REG_REGION_META, region_meta)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
|
let m = lua.create_table()?;
|
|
|
|
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
|
|
// ref kit hides tab slices, cooldown swipes, money coins…). A hidden region draws nothing;
|
|
// IsVisible additionally requires the owner frame's effective visibility, mirroring the
|
|
// frame-side pair.
|
|
m.set(
|
|
"Show",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
model.region_data.entry(rh).or_default().hidden = false;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"Hide",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
model.region_data.entry(rh).or_default().hidden = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"IsShown",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
|
|
Ok(if shown { Value::Integer(1) } else { Value::Nil })
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"IsVisible",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
|
|
let owner_visible = model
|
|
.arena
|
|
.region(rh)
|
|
.and_then(|r| model.arena.frame(r.owner))
|
|
.is_some_and(|f| f.effective_visible);
|
|
Ok(if shown && owner_visible {
|
|
Value::Integer(1)
|
|
} else {
|
|
Value::Nil
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// SetAlpha/GetAlpha — the region's own alpha, distinct from the owner frame's. The ref kit reads
|
|
// it back to ramp a texture (CastingBarFrame's completion flash does
|
|
// `CastingBarFlash:SetAlpha(GetAlpha() + CASTING_BAR_FLASH_STEP)`), so the getter must return the
|
|
// region's value, never the frame's. Draw law + the open question: [`RegionData::alpha`].
|
|
m.set(
|
|
"SetAlpha",
|
|
lua.create_function(|lua, (this, alpha): (Table, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
model.region_data.entry(rh).or_default().alpha = Some(alpha.clamp(0.0, 1.0));
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetAlpha",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
Ok(model
|
|
.region_data
|
|
.get(&rh)
|
|
.and_then(|d| d.alpha)
|
|
.unwrap_or(1.0))
|
|
})?,
|
|
)?;
|
|
|
|
m.set(
|
|
"SetVertexColor",
|
|
lua.create_function(
|
|
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
model.region_data.entry(rh).or_default().color = Some([r, g, b, a.unwrap_or(1.0)]);
|
|
Ok(())
|
|
},
|
|
)?,
|
|
)?;
|
|
m.set(
|
|
"SetTexture",
|
|
lua.create_function(
|
|
|lua, (this, arg, g, b, a): (Table, Value, Option<f32>, Option<f32>, Option<f32>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
// A plain SetTexture makes the region ordinary again — drop any portrait circular mask
|
|
// and any live-unit-portrait binding.
|
|
data.circular = false;
|
|
data.portrait_unit = None;
|
|
match &arg {
|
|
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
|
|
// art with the empty string (QuestLogFrame.lua:165-166).
|
|
Value::String(s) if s.to_str()?.is_empty() => {
|
|
data.texture = None;
|
|
data.color = None;
|
|
}
|
|
Value::String(s) => {
|
|
data.texture = Some(s.to_str()?.to_string());
|
|
}
|
|
Value::Number(_) | Value::Integer(_) => {
|
|
data.color = Some([
|
|
as_f32(&arg),
|
|
g.unwrap_or(0.0),
|
|
b.unwrap_or(0.0),
|
|
a.unwrap_or(1.0),
|
|
]);
|
|
}
|
|
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
|
|
// texture region draws nothing.
|
|
Value::Nil => {
|
|
data.texture = None;
|
|
data.color = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
},
|
|
)?,
|
|
)?;
|
|
// SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to
|
|
// its inscribed circle). The client's portraits are circular (model or icon fallback); the frame
|
|
// ring is a thin band whose transparent corners would otherwise show the square texture's edges.
|
|
// The live API's `SetPortraitToTexture(texture, path)` is a global crop helper; ours is the
|
|
// region-method face of the same intent. This is the *icon/path* portrait — distinct from the
|
|
// `SetPortraitTexture(region, unit)` live model bake, so it drops any live-unit binding.
|
|
// `SetTexture` on the same region clears the flag (a plain texture again).
|
|
m.set(
|
|
"SetPortraitToTexture",
|
|
lua.create_function(|lua, (this, path): (Table, String)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.texture = Some(path);
|
|
data.circular = true;
|
|
data.portrait_unit = None;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetText",
|
|
lua.create_function(|lua, (this, text): (Table, Option<String>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.text = text;
|
|
// Fresh text draws whole — an armed write-on gradient belongs to the old string.
|
|
data.alpha_gradient = None;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// SetAlphaGradient(start, length) — the per-character write-on reveal (CSimpleFontString;
|
|
// the quest-description machinery, ref QuestFrame.lua:548/558). Returns whether `start` is
|
|
// still inside the text (chars) — the ref's OnUpdate loop advances until this goes false.
|
|
m.set(
|
|
"SetAlphaGradient",
|
|
lua.create_function(|lua, (this, start, length): (Table, f32, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.alpha_gradient = Some((start, length));
|
|
let chars = data.text.as_deref().map_or(0, |t| t.chars().count());
|
|
Ok(start < chars as f32)
|
|
})?,
|
|
)?;
|
|
// SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's
|
|
// positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets).
|
|
m.set(
|
|
"SetFormattedText",
|
|
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
|
|
let format: mlua::Function = lua
|
|
.globals()
|
|
.get::<Table>("string")?
|
|
.get::<mlua::Function>("format")?;
|
|
let text: String = format.call(args)?;
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let data = model.region_data.entry(rh).or_default();
|
|
data.text = Some(text);
|
|
data.alpha_gradient = None;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetText",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let text = {
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
model.region_data.get(&rh).and_then(|d| d.text.clone())
|
|
};
|
|
match text {
|
|
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
// SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes
|
|
// draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap).
|
|
m.set(
|
|
"SetBlendMode",
|
|
lua.create_function(|lua, (this, mode): (Table, String)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD");
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// Region explicit size (drawn centered on the owner; region anchors come later).
|
|
m.set(
|
|
"SetWidth",
|
|
lua.create_function(|lua, (this, w): (Table, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let d = model.region_data.entry(rh).or_default();
|
|
let new = Some((w, d.size.map_or(0.0, |s| s.1)));
|
|
let changed = !size_bits_eq(d.size, new);
|
|
d.size = new;
|
|
if changed {
|
|
model.touch_layout();
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetHeight",
|
|
lua.create_function(|lua, (this, h): (Table, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let d = model.region_data.entry(rh).or_default();
|
|
let new = Some((d.size.map_or(0.0, |s| s.0), h));
|
|
let changed = !size_bits_eq(d.size, new);
|
|
d.size = new;
|
|
if changed {
|
|
model.touch_layout();
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetSize",
|
|
lua.create_function(|lua, (this, w, h): (Table, f32, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let d = model.region_data.entry(rh).or_default();
|
|
let new = Some((w, h));
|
|
let changed = !size_bits_eq(d.size, new);
|
|
d.size = new;
|
|
if changed {
|
|
model.touch_layout();
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure
|
|
// round-trip ([`super::UiScript::set_measured_text`]) — the client asks its font engine for the
|
|
// laid-out string's metrics exactly here (`fontstring.md`), and the tooltip's auto-size sums
|
|
// these to fit its lines. `0` until the string has been measured (a frame's latency; converges).
|
|
// The stored measure only counts while its key matches the CURRENT text/font/wrap
|
|
// ([`RegionData::measure_key`]): after a SetText the old string's width is not this string's
|
|
// metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on
|
|
// the previous header's width. A poll-until-nonzero caller (the chat header machine) now
|
|
// converges on the RIGHT measure instead of settling on a stale one.
|
|
// `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the
|
|
// real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63).
|
|
fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> {
|
|
let rh = region_handle_of(lua, this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
let d = model.region_data.get(&rh);
|
|
let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key()));
|
|
let size = d.and_then(|d| d.size);
|
|
let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0);
|
|
let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0);
|
|
Ok((w, h))
|
|
}
|
|
m.set(
|
|
"GetStringWidth",
|
|
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?,
|
|
)?;
|
|
m.set(
|
|
"GetStringHeight",
|
|
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
|
)?;
|
|
m.set(
|
|
"GetWidth",
|
|
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?,
|
|
)?;
|
|
m.set(
|
|
"GetHeight",
|
|
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
|
)?;
|
|
|
|
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin
|
|
// in object.rs). An anchored region reads its own resolved rect; an unanchored one has no
|
|
// rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve.
|
|
for (name, pick) in [
|
|
("GetLeft", 0u8),
|
|
("GetRight", 1u8),
|
|
("GetTop", 2u8),
|
|
("GetBottom", 3u8),
|
|
] {
|
|
m.set(
|
|
name,
|
|
lua.create_function(move |lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
Ok(model.region_resolved.get(&rh).map(|r| match pick {
|
|
0 => r.left,
|
|
1 => r.right,
|
|
2 => r.top,
|
|
_ => r.bottom,
|
|
}))
|
|
})?,
|
|
)?;
|
|
}
|
|
m.set(
|
|
"SetDrawLayer",
|
|
lua.create_function(|lua, (this, layer, sub): (Table, String, Option<i64>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let dl = draw_layer_from_str(&layer)
|
|
.ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
if let Some(region) = model.arena.region_mut(rh) {
|
|
region.draw_layer = dl;
|
|
if let Some(s) = sub {
|
|
region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8;
|
|
}
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions
|
|
// ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo`
|
|
// defaults to the **owner frame**; a named one may be a frame or a sibling region (the real
|
|
// 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])
|
|
},
|
|
)?,
|
|
)?;
|
|
m.set(
|
|
"ClearAllPoints",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
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();
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetAllPoints",
|
|
lua.create_function(|lua, (this, target): (Table, Value)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let owner = region_owner_id(&mut model, rh);
|
|
let rel_id = resolve_target(&mut model, &target, 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),
|
|
];
|
|
let data = model.region_data.entry(rh).or_default();
|
|
let same = data.anchors.len() == 2
|
|
&& data
|
|
.anchors
|
|
.iter()
|
|
.zip(&pair)
|
|
.all(|(a, b)| anchor_bits_eq(a, b));
|
|
if !same {
|
|
data.anchors.clear();
|
|
data.anchors.extend_from_slice(&pair);
|
|
model.touch_layout();
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`).
|
|
m.set(
|
|
"SetJustifyH",
|
|
lua.create_function(|lua, (this, j): (Table, String)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let jh = match j.to_ascii_uppercase().as_str() {
|
|
"LEFT" => JustifyH::Left,
|
|
"RIGHT" => JustifyH::Right,
|
|
_ => JustifyH::Center,
|
|
};
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.justify_h = jh;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") — a FontString's vertical justification (XML `justifyV`).
|
|
m.set(
|
|
"SetJustifyV",
|
|
lua.create_function(|lua, (this, j): (Table, String)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let jv = match j.to_ascii_uppercase().as_str() {
|
|
"TOP" => JustifyV::Top,
|
|
"BOTTOM" => JustifyV::Bottom,
|
|
_ => JustifyV::Middle,
|
|
};
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.justify_v = jv;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the
|
|
// later-era Texture API, shipped early: the world-map player arrow's stand-in rotation —
|
|
// see `QuadContent::Texture::rotation`). No-arg/nil resets to 0.
|
|
m.set(
|
|
"SetRotation",
|
|
lua.create_function(|lua, (this, radians): (Table, Option<f32>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.rotation = radians.unwrap_or(0.0);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// SetTexCoord(left, right, top, bottom) — the 4-edge form (XML `<TexCoords>`): a UV sub-rect in
|
|
// 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art
|
|
// (decision 0084). SetTexCoord(ULx,ULy, LLx,LLy, URx,URy, LRx,LRy) — the 8-arg affine form: an
|
|
// arbitrary UV quad (rotation/shear — the reference's `DrawRouteLine` route lines), stored per
|
|
// corner in the renderer's screen winding.
|
|
m.set(
|
|
"SetTexCoord",
|
|
lua.create_function(|lua, (this, rest): (Table, mlua::Variadic<f32>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let coords = match rest.len() {
|
|
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
|
|
// The live arg order is UL, LL, UR, LR (corner pairs); [`TexCoords::Corners`]
|
|
// stores screen order [TL, TR, BR, BL].
|
|
8 => Some(TexCoords::Corners([
|
|
[rest[0], rest[1]], // UL → TL
|
|
[rest[4], rest[5]], // UR → TR
|
|
[rest[6], rest[7]], // LR → BR
|
|
[rest[2], rest[3]], // LL → BL
|
|
])),
|
|
// No args resets to the full texture (the live API's clear form).
|
|
0 => None,
|
|
n => {
|
|
return Err(mlua::Error::runtime(format!(
|
|
"SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}"
|
|
)))
|
|
}
|
|
};
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.tex_coords = coords;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// GetTexCoord() → left, right, top, bottom (the 4-edge form; full texture if never set; an
|
|
// affine mapping reports its bounding edges).
|
|
m.set(
|
|
"GetTexCoord",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
let [l, r, t, b] = model
|
|
.region_data
|
|
.get(&rh)
|
|
.and_then(|d| d.tex_coords)
|
|
.map(|tc| tc.edges())
|
|
.unwrap_or([0.0, 1.0, 0.0, 1.0]);
|
|
Ok((l, r, t, b))
|
|
})?,
|
|
)?;
|
|
// SetFontObject("GameFontNormal") — re-point this FontString at a named virtual Font object: its
|
|
// resolved paint (face/height/color/outline) is copied into the region as the new default. An
|
|
// explicit SetFont/SetTextColor afterward overrides. An unknown name errors (never silently no-op).
|
|
m.set(
|
|
"SetFontObject",
|
|
lua.create_function(|lua, (this, name): (Table, String)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let Some(font) = model.font_objects.get(&name).cloned() else {
|
|
return Err(mlua::Error::runtime(format!(
|
|
"SetFontObject: no font object named '{name}' is registered"
|
|
)));
|
|
};
|
|
let d = model.region_data.entry(rh).or_default();
|
|
d.font_object = Some(name);
|
|
d.font_path = font.font;
|
|
d.font_height = font.height;
|
|
d.outline = font.outline;
|
|
d.font_shadow = font.shadow;
|
|
if let Some(c) = font.color {
|
|
d.color = Some(c);
|
|
}
|
|
if let Some(j) = font.justify_h {
|
|
d.justify_h = j;
|
|
}
|
|
if let Some(j) = font.justify_v {
|
|
d.justify_v = j;
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// GetFontObject() → the name of the last font object this FontString resolved (or nil).
|
|
m.set(
|
|
"GetFontObject",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
match model
|
|
.region_data
|
|
.get(&rh)
|
|
.and_then(|d| d.font_object.clone())
|
|
{
|
|
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
// SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and
|
|
// the XML `font=`/`<FontHeight>`/`outline=` join). `flags` is an OUTLINETYPE-ish string
|
|
// ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the
|
|
// current face (so a FontString with only `<FontHeight>` retains its inherited object's font).
|
|
// Returns true (the live API returns whether the font loaded; we always accept — face
|
|
// availability is the renderer's concern).
|
|
m.set(
|
|
"SetFont",
|
|
lua.create_function(
|
|
|lua, (this, path, height, flags): (Table, Option<String>, Option<f32>, Option<String>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
let d = model.region_data.entry(rh).or_default();
|
|
if let Some(p) = path.filter(|p| !p.is_empty()) {
|
|
d.font_path = Some(p);
|
|
}
|
|
if let Some(h) = height {
|
|
d.font_height = Some(h);
|
|
}
|
|
if let Some(f) = flags {
|
|
d.outline = match f.to_ascii_uppercase() {
|
|
f if f.contains("THICK") => Outline::Thick,
|
|
f if f.contains("OUTLINE") => Outline::Normal,
|
|
_ => Outline::None,
|
|
};
|
|
}
|
|
Ok(true)
|
|
},
|
|
)?,
|
|
)?;
|
|
// SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified,
|
|
// wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit
|
|
// `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored
|
|
// as the distinct [`RegionData::text_height`] — the font object is untouched, so GetFont
|
|
// keeps reporting the face's own height like the real API.
|
|
m.set(
|
|
"SetTextHeight",
|
|
lua.create_function(|lua, (this, height): (Table, f32)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.text_height = Some(height);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
// GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set).
|
|
m.set(
|
|
"GetFont",
|
|
lua.create_function(|lua, this: Table| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model");
|
|
let d = model.region_data.get(&rh);
|
|
let path = d.and_then(|d| d.font_path.clone());
|
|
let height = d.and_then(|d| d.font_height);
|
|
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
|
|
let path = match path {
|
|
Some(p) => Value::String(lua.create_string(&p)?),
|
|
None => Value::Nil,
|
|
};
|
|
Ok((path, height, flags))
|
|
})?,
|
|
)?;
|
|
// SetTextColor(r, g, b [, a]) — a FontString's text color (distinct name from SetVertexColor,
|
|
// same storage in v1: the resolved text color the renderer draws with).
|
|
m.set(
|
|
"SetTextColor",
|
|
lua.create_function(
|
|
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
|
let rh = region_handle_of(lua, &this)?;
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model")
|
|
.region_data
|
|
.entry(rh)
|
|
.or_default()
|
|
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
|
Ok(())
|
|
},
|
|
)?,
|
|
)?;
|
|
|
|
lua.set_named_registry_value(REG_REGION_METHODS, m)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The layout [`super::layout::Handle`] a region anchors to by default: its **owner frame**'s id
|
|
/// (minted if needed), or [`SCREEN`] if the region has somehow lost its owner.
|
|
fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
|
|
match model.arena.region(rh).map(|r| r.owner) {
|
|
Some(owner) => model.frame_id(owner),
|
|
None => SCREEN,
|
|
}
|
|
}
|
|
|
|
/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a frame
|
|
/// name, or nil) to a layout id, defaulting to `owner` when absent/unresolved.
|
|
fn resolve_target(model: &mut Model, target: &Value, 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(s) => s
|
|
.to_str()
|
|
.ok()
|
|
.and_then(|n| {
|
|
// Frames first (the client's global namespace is one; frames publish before their
|
|
// regions build), then the region-name registry — the real XML anchors regions to
|
|
// sibling regions by name (merchant label plate → `$parentSlot`).
|
|
model
|
|
.arena
|
|
.lookup(n.as_ref())
|
|
.map(|h| model.frame_id(h))
|
|
.or_else(|| model.region_names.get(n.as_ref()).copied())
|
|
})
|
|
.unwrap_or_else(|| {
|
|
// The owner fallback matches the frame path, 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;
|
|
// ItemTextFrame's scrollbar track landed on the parchment this way). Warn
|
|
// instead of silently misdirecting the anchor.
|
|
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.warnings.push(format!(
|
|
"SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner",
|
|
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
|
|
));
|
|
owner
|
|
}),
|
|
_ => owner,
|
|
}
|
|
}
|
|
|
|
/// Bit-exact equality for a region's explicit size — the layout gate's own lens
|
|
/// (`InputFingerprint::input` feeds `f32::to_bits`), so a setter's no-op detection and the gate
|
|
/// can never disagree; see [`anchor_bits_eq`].
|
|
fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool {
|
|
match (a, b) {
|
|
(None, None) => true,
|
|
(Some((aw, ah)), Some((bw, bh))) => {
|
|
aw.to_bits() == bw.to_bits() && ah.to_bits() == bh.to_bits()
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// `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.
|
|
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}'")))?;
|
|
let rh = region_handle_of(lua, this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model");
|
|
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], 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 data = model.region_data.entry(rh).or_default();
|
|
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
|
|
// Same no-op law as the frame twin (`layout_methods::set_point`): idempotent only when the
|
|
// bit-identical anchor already holds the tail and no earlier entry carries this point.
|
|
let same_at_tail = data.anchors.last().is_some_and(|a| anchor_bits_eq(a, &new))
|
|
&& !data.anchors[..data.anchors.len() - 1]
|
|
.iter()
|
|
.any(|a| a.point == point);
|
|
if !same_at_tail {
|
|
data.anchors.retain(|a| a.point != point);
|
|
data.anchors.push(new);
|
|
model.touch_layout();
|
|
}
|
|
Ok(())
|
|
}
|