ui: texture masks, the later-era texture dials, and the pools a canvas map needs

The mask pair is the one that matters and nothing can stand in for it. 1.12 has
no texture masking at all — its single masked thing is the minimap disc, cut
engine-side — and no idiom reaches the same end, because the point is that a
rectangular texture draws as a non-rectangular shape. A seamless world map is
that operation and nothing else: a zone's art is a rectangle of parchment and
the map wants it cut to the zone's coastline.

It was affordable because the render pass already had the slot. `UiQuad::mask`
is a screen-anchored alpha mask built for the minimap's fixed circle with tiles
panning under it, which is exactly `AddMaskTexture`'s semantics — the mask is
another region with its own anchors, and the subject keeps the fragments its
alpha covers at that place. So this is plumbing, not a new material.

One mask per quad, said rather than hidden: widening it is a second texture
binding and a second uniform on every UI quad in the client. The list is kept
whole, because the order decides the winner — first added, which for a stitched
map is the land mask. Two traps are pinned by tests: a mask region is skipped at
extract rather than marked hidden, since its rect is what it covers; and a mask
whose art is missing drops the mask, not the quad, because zero coverage would
blank the art it was meant to shape.

`Texture:SetRotation` was nearly free — the renderer has carried the angle since
the minimap arrow, so this is the Lua setter for it. `SetDesaturation` is the
fractional form of 1.12's own flag, rounded onto the one flag this renderer has,
with the getter answering the caller's own number. The tile pair is applied: the
host computes the repeat count once the art has loaded.

`SetPropagateKeyboardInput` is stored, not applied: 1.12 already always
propagates, so there is no swallow mode for false to select. `EditBox:HasFocus`
publishes a fact this client already tracks and never exposed.

Corpus: 85 ok / 1 fail, roster and digest unchanged. The 12 data-half reds are
byte-identical to HEAD's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-01 21:15:55 -07:00
co-authored by Claude Opus 5
parent 87f93fea46
commit b49856c5cb
14 changed files with 983 additions and 16 deletions
+130 -2
View File
@@ -1319,6 +1319,8 @@ fn convert_entry(
portrait_unit,
rotation,
desaturated,
mask,
tile,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): the model bake, or the
// reference's 2D TemporaryPortrait while the model streams; no entry yet draws nothing.
@@ -1382,8 +1384,15 @@ fn convert_entry(
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone.
let wrap = tiling_axes(&uv);
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone;
// `SetHorizTile`/`SetVertTile` ask for the same wrap without naming a count, so they
// join it here and the count is computed below, once the art's texel size is known.
let explicit_uv = tex_coords.is_some();
let mut uv = uv;
let wrap = {
let (h, v) = tiling_axes(&uv);
(h || (tile.0 && !explicit_uv), v || (tile.1 && !explicit_uv))
};
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
@@ -1426,6 +1435,36 @@ fn convert_entry(
}
_ => None,
};
// `SetHorizTile`/`SetVertTile`'s repeat count: the region's own span over the art's,
// which is what "tile at its native size" means. Taken here because the texel size is
// only known once the art has loaded. A missing image, a zero-texel axis or an
// explicit `SetTexCoord` leaves the mapping alone.
if (tile.0 || tile.1) && !explicit_uv {
if let Some(sz) = handle
.as_ref()
.and_then(|h| images.get(h))
.map(|img| img.texture_descriptor.size)
{
let repeats = |span: f32, texels: u32| {
if texels > 0 {
span / texels as f32
} else {
1.0
}
};
let u = if tile.0 {
repeats(rect.width(), sz.width)
} else {
1.0
};
let v = if tile.1 {
repeats(rect.height(), sz.height)
} else {
1.0
};
uv = UvRect::from_tex_coords([0.0, u, 0.0, v]);
}
}
// A crop's magnified edge would filter in the neighbouring atlas cell.
let uv_clamp = handle
.as_ref()
@@ -1447,6 +1486,18 @@ fn convert_entry(
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
rotation: -rotation,
desaturated,
// `AddMaskTexture`: the mask's own art and rect, converted to screen space the
// same way as `rect`. A mask whose art the archives lack drops the mask, not the
// quad — a missing cut-out shows the whole rectangle, which is the same thing the
// caller sees when it never had a mask to add.
mask: mask.and_then(|m| {
let art = assets.as_mut()?.sprite_texture(&m.path, images)?;
let r = m.rect;
Some(crate::ui_pass::UiQuadMask {
texture: art,
rect: Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s),
})
}),
..default()
});
}
@@ -1831,6 +1882,83 @@ mod clip_plumb_tests {
);
}
/// A mask whose art the archives lack drops the **mask**, not the quad.
///
/// Worth pinning because the other reading is tempting and wrong: a mask that cannot load is
/// zero coverage, and honouring that would erase the art it was meant to shape — a stitched
/// map would go blank wherever a generated mask was missing, which reads as the map's own art
/// failing. Showing the whole rectangle is what the caller sees when it never had a mask.
///
/// Runs with no `WorldAssets` at all, which is the same path a missing file takes: the quad is
/// a pathless solid so it still draws, and the mask asks for art and gets none.
#[test]
fn a_mask_whose_art_is_missing_drops_the_mask_and_keeps_the_quad() {
let script = UiScript::new().unwrap();
script
.run(
r#"
local f = CreateFrame("Frame", "MaskPlumb")
f:SetPoint("TOPLEFT", 0, 0)
f:SetWidth(50); f:SetHeight(50)
local m = f:CreateTexture(nil, "ARTWORK")
m:SetTexture(0, 0, 1)
m:SetAllPoints()
local cut = f:CreateMaskTexture(nil, "ARTWORK")
cut:SetTexture("Interface\\AddOns\\NoSuch\\mask")
cut:SetAllPoints()
m:AddMaskTexture(cut)
"#,
)
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let marker: &UiQuad = quads
.iter()
.find(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
.expect("the solid still draws");
assert!(
marker.mask.is_none(),
"a mask with no art is no mask, not an empty one"
);
// And the mask region itself emitted nothing: one quad for the frame slot, one for the
// solid, and none for the cut-out.
assert_eq!(
quads
.iter()
.filter(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
.count(),
1
);
}
#[test]
fn an_unclipped_texture_quad_carries_no_clip() {
let script = UiScript::new().unwrap();
@@ -261,6 +261,104 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `CreateMaskTexture(name, layer, template, subLayer)`: a Texture that shapes other regions
// through `Texture:AddMaskTexture` and never draws itself (`region/paint.rs` has the pair, and
// `RegionData::is_mask` the skip).
//
// 1.12 has no masking and therefore no mask region. It is built because a seamless map is
// nothing but masking — a zone's rectangular parchment cut to the zone's coastline — and
// because the method is called **unguarded** where it is used (`Map/Close.lua:159` builds the
// continent land mask before any texture can ask for it).
//
// Built over the stock `CreateTexture` on this same table rather than beside it, so a mask is
// an ordinary Texture in every other respect: the callers set its art, size and anchors with
// the ordinary methods, and its *rect* is what the mask covers. `subLayer` is accepted and
// ignored for the same reason the draw layer is read at all — a mask is never drawn, so
// neither places it.
m.set(
"CreateMaskTexture",
lua.create_function(
|lua, (this, name, layer, template, _sub): (Table, Value, Value, Value, Value)| {
let create: mlua::Function = this.get("CreateTexture")?;
let wrapper: Table = create.call((this, name, layer, template))?;
let rh = crate::script::region::region_handle_of(lua, &wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().is_mask = true;
Ok(wrapper)
},
)?,
)?;
// `EditBox:HasFocus()` and `EditBox:SetCursorPosition(n)`, both **beyond 1.12**: its EditBox
// table has `SetFocus`/`ClearFocus` and no getter, and no cursor setter at all
// (`script/editbox/methods.rs` says so where the getter would be), so an addon of that era
// keeps its own flag and never moves the caret from Lua.
//
// Both are built because both are called unguarded on this install's own libraries' widgets:
// `HasFocus` decides whether a dropped autocomplete list closes (`Map/Nav.lua:374`, inside an
// `OnEditFocusLost` callback, so a nil method raises every time a search box loses focus), and
// `SetCursorPosition(0)` scrolls a long read-only link box back to its start after
// `HighlightText` (`Map/News.lua:282`).
//
// `HasFocus` is the one focus owner (`Model::focused_editbox`), which is the fact 1.12 already
// tracks — it just never published it. `SetCursorPosition` lands through the same clamp and
// UTF-8 snap `HighlightText` uses, with the selection collapsed to the caret, which is what
// the later-era method does.
{
let e: Table = lua.named_registry_value(crate::script::editbox::REG_EDITBOX_METHODS)?;
e.set(
"HasFocus",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(crate::script::binding_abi::flag(
model.focused_editbox == Some(h),
))
})?,
)?;
e.set(
"SetCursorPosition",
lua.create_function(|lua, (this, pos): (Table, Option<i64>)| {
let h = frame_handle_of(lua, &this)?;
let pos = pos.unwrap_or(0);
crate::script::editbox::set_cursor_position(lua, h, pos);
Ok(())
})?,
)?;
}
// `SetPropagateKeyboardInput(on)` / `GetPropagateKeyboardInput()`: 4.x's switch for whether a
// frame with an `OnKeyDown` handler lets the key reach the next frame and the keybinding
// system, or swallows it.
//
// **Stored, not applied**, and the reason is that 1.12's answer is "always propagate": its
// key routing hands a `WorldFrame` key to the binding system regardless of what any frame's
// handler did, so there is no swallow mode here for `false` to select. Honouring it would mean
// building that mode, and refusing it costs a caller its whole key path — the one here arms an
// invisible catcher frame and flips the flag inside its own handler
// (`Map/Sketch.lua:579-585`), so a nil method raises in a key handler, once per keypress.
// The pair round-trips, so a caller reading back what it wrote gets its own answer, and
// `true` — the value every caller ends up on — is what this client does anyway.
m.set(
"SetPropagateKeyboardInput",
lua.create_function(|lua, (this, on): (Table, Option<Value>)| {
let h = frame_handle_of(lua, &this)?;
let on = !matches!(on, None | Some(Value::Nil) | Some(Value::Boolean(false)));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.propagate_keyboard.insert(h, on);
Ok(())
})?,
)?;
m.set(
"GetPropagateKeyboardInput",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let on = model.propagate_keyboard.get(&h).copied().unwrap_or(true);
Ok(crate::script::binding_abi::flag(on))
})?,
)?;
// `IsEventRegistered(event)`: 1 or nil, the 1.12 predicate shape. A frame in all-events mode
// (`RegisterAllEvents`) answers 1 for anything, since that is what it will receive.
m.set(
@@ -112,6 +112,97 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// secure-template system at all, so nothing here is ever locked down.
g.set("InCombatLockdown", lua.create_function(|_, ()| Ok(false))?)?;
// `Ambiguate(fullName, context)`: 4.x's cross-realm name shortener, which strips a
// `Name-Realm` suffix when the context says the realm is implied.
//
// **The identity function on the part that matters**, and that is the answer rather than a
// stub: 1.12 has no connected realms and no cross-realm names, so a unit name here never
// carries a realm and there is nothing to strip. The `-Realm` split is still applied, because
// a *custom* realm can hand out a hyphenated name and the caller's whole purpose is to get the
// short one; `"none"` is the context every caller in the corpus passes and it means "always
// strip", which is what this does unconditionally. A nil name answers nil, as upstream does.
g.set(
"Ambiguate",
lua.create_function(|_, (name, _context): (Option<String>, Option<String>)| {
Ok(name.map(|n| match n.split_once('-') {
Some((short, _realm)) if !short.is_empty() => short.to_string(),
_ => n,
}))
})?,
)?;
// `IsModifiedClick(action)`: whether the modifiers held match the binding for one of the
// named click actions (`CHATLINK`, `SELFCAST`, `COMPAREITEMS`, …).
//
// 1.12 has the *bindings* — they are `SetModifiedClick`/`GetModifiedClick` over a handful of
// CVar-backed rows, and its own FrameXML reads them with `GetModifiedClick("CHATLINK")` then
// tests the key itself — but it registers no predicate that does both steps. So this is the
// composition of two verbs this client already answers, not new state: the bound modifier
// name for the action, then that modifier's own key test.
//
// An unknown action answers false rather than raising: every caller guards the name
// (`Map/Nav.lua:161` is `IsModifiedClick and IsModifiedClick("CHATLINK")`) and a raise inside
// a click handler would take the click's whole path with it.
g.set(
"IsModifiedClick",
lua.create_function(|lua, action: Option<String>| {
let Some(action) = action else {
return Ok(false);
};
let g = lua.globals();
let get: Option<mlua::Function> = g.get("GetModifiedClick")?;
let Some(get) = get else {
return Ok(false);
};
let bound: Option<String> = get.call(action.as_str())?;
let Some(bound) = bound else {
return Ok(false);
};
let down = |name: &str| -> mlua::Result<bool> {
let f: Option<mlua::Function> = g.get(name)?;
Ok(match f {
Some(f) => !matches!(f.call::<Value>(())?, Value::Nil | Value::Boolean(false)),
None => false,
})
};
Ok(match bound.to_ascii_uppercase().as_str() {
"SHIFT" => down("IsShiftKeyDown")?,
"CTRL" => down("IsControlKeyDown")?,
"ALT" => down("IsAltKeyDown")?,
// `NONE`, and anything the table does not spell: no modifier is required, so a
// plain click already is the modified click.
"NONE" => true,
_ => false,
})
})?,
)?;
// `GetQuestDifficultyColor(level)`: 2.x's rename of 1.12's own `GetDifficultyColor`, which is
// FrameXML's (`reference/1.12-globals.tsv` files it under `framexml`) and so is loaded off the
// player's chain after this. Registered as a **forwarder** rather than a copy of the table, so
// the colours are whatever that file says and the two spellings cannot drift; a VM with no
// FrameXML answers nil, which is what the name did before.
g.set(
"GetQuestDifficultyColor",
lua.create_function(|lua, level: Value| {
let f: Option<mlua::Function> = lua.globals().get("GetDifficultyColor")?;
match f {
Some(f) => f.call::<Value>(level),
None => Ok(Value::Nil),
}
})?,
)?;
// `GetUnitSpeed(unit)`: 3.x's current, running and swim speeds in yards per second.
//
// **Not built**, and the reason is that answering would be inventing the number: 1.12 sends no
// speed field for any unit — `MOVEMENTFLAG`s and the server's speed packets are not in this
// client's unit model at all — so the only honest answer is the absent one. Its single caller
// here already reads it that way, gated on `if GetUnitSpeed then` and falling back to a
// distance-over-time estimate of its own (`Map/Extras.lua:418`), which is a better number than
// anything this could make up. Listed in `docs/ADDON_COMPAT.md` rather than registered,
// because a registered `0` would read as "standing still" forever.
Ok(())
}
@@ -19,9 +19,9 @@
//! is exactly how pfUI iterates it (`modules/tooltip.lua:228`). A "tidier" version storing objects
//! as array values would break that loop silently.
//!
//! `CreateFramePool` and `CreateFontStringPool` are **not** built: nothing in the installed set
//! calls either, and `docs/ADDON_COMPAT.md` records them as unbuilt rather than guessed at. They
//! are the same mixin over a different factory, so adding one later is small.
//! `CreateFramePool` and `CreateFontStringPool` are now built too, over the same mixin and their
//! own factories — a canvas map pools a frame per pin and a FontString per label, and both call
//! them at file scope the way pfUI calls `CreateTexturePool`.
use mlua::Lua;
@@ -136,6 +136,72 @@ function CreateTexturePool(parent, layer, subLayer, textureTemplate, resetterFun
resetterFunc or TexturePool_HideAndClearAnchors)
return pool
end
FramePoolMixin = CreateFromMixins(ObjectPoolMixin)
-- Upstream names its frames `<parentName>-<frameType>-<n>`, which matters: a pooled frame has to
-- be reachable by name for a template's own `$parent` children to resolve. An anonymous parent
-- leaves the pooled frames anonymous too, which is also upstream's behaviour.
local function FramePoolFactory(pool)
pool.numFrames = (pool.numFrames or 0) + 1
local name = nil
if pool.parent and pool.parent.GetName and pool.parent:GetName() then
name = pool.parent:GetName() .. "-" .. (pool.frameType or "Frame") .. "-" .. pool.numFrames
end
return CreateFrame(pool.frameType or "Frame", name, pool.parent, pool.frameTemplate)
end
function FramePoolMixin:OnLoad(frameType, parent, frameTemplate, resetterFunc, forbidden)
ObjectPoolMixin.OnLoad(self, FramePoolFactory, resetterFunc)
self.frameType = frameType
self.parent = parent
self.frameTemplate = frameTemplate
self.forbidden = forbidden
end
function FramePool_Hide(pool, frame)
frame:Hide()
end
function FramePool_HideAndClearAnchors(pool, frame)
frame:Hide()
frame:ClearAllPoints()
end
function CreateFramePool(frameType, parent, frameTemplate, resetterFunc, forbidden)
local pool = CreateFromMixins(FramePoolMixin)
pool:OnLoad(frameType or "Frame", parent, frameTemplate,
resetterFunc or FramePool_HideAndClearAnchors, forbidden)
return pool
end
FontStringPoolMixin = CreateFromMixins(ObjectPoolMixin)
-- 1.12's `CreateFontString(name, layer, inherits)` takes no sub-layer either, so `subLayer` is
-- kept on the pool and not passed, exactly as `TexturePoolFactory` above does.
local function FontStringPoolFactory(pool)
return pool.parent:CreateFontString(nil, pool.layer, pool.fontStringTemplate)
end
function FontStringPoolMixin:OnLoad(parent, layer, subLayer, fontStringTemplate, resetterFunc)
ObjectPoolMixin.OnLoad(self, FontStringPoolFactory, resetterFunc)
self.parent = parent
self.layer = layer
self.subLayer = subLayer
self.fontStringTemplate = fontStringTemplate
end
function FontStringPool_HideAndClearAnchors(pool, fontString)
fontString:Hide()
fontString:ClearAllPoints()
end
function CreateFontStringPool(parent, layer, subLayer, fontStringTemplate, resetterFunc)
local pool = CreateFromMixins(FontStringPoolMixin)
pool:OnLoad(parent, layer, subLayer, fontStringTemplate or "",
resetterFunc or FontStringPool_HideAndClearAnchors)
return pool
end
"#;
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
+9 -1
View File
@@ -27,7 +27,7 @@ use crate::order::{self, DrawLayer, ZTarget};
use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
/// Registry key of the EditBox method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_EDITBOX_METHODS: &str = "__benilla_editbox_methods";
pub(in crate::script) const REG_EDITBOX_METHODS: &str = "__benilla_editbox_methods";
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Public entry points
@@ -325,6 +325,14 @@ fn highlight_text(lua: &Lua, h: FrameHandle, start: i64, end: i64) {
with_eb(lua, h, |eb| eb.highlight_text(start, end));
}
/// `EditBox:SetCursorPosition(n)`, the later-era caret setter
/// ([`crate::script::compat`]'s surface row): the caret at character offset `n`, selection
/// collapsed. Through [`EditBoxState::highlight_text`]'s own clamp and UTF-8 snap, with the two
/// ends equal, so there is one definition of "where in this string can a caret sit".
pub(in crate::script) fn set_cursor_position(lua: &Lua, h: FrameHandle, pos: i64) {
with_eb(lua, h, |eb| eb.highlight_text(pos, pos));
}
// ── text-region sync ─────────────────────────────────────────────────────────────────────────
/// Write the display string into the text region; a `password` box shows one `*` per character.
+19
View File
@@ -260,6 +260,13 @@ impl UiScript {
if data_ref.is_some_and(super::nameplate::is_unpainted_glow) {
continue;
}
// A mask texture (`Frame:CreateMaskTexture`) shapes other regions and is never
// drawn itself. It is still laid out and still resolves, because its rect is
// what the mask covers — which is why this skip is here and not a `hidden`
// flag: hiding it would take it out of the layout too.
if data_ref.is_some_and(|d| d.is_mask) {
continue;
}
let mut data = data_ref.cloned().unwrap_or_default();
// One hop (region combine `0x772180`): the region's alpha times its owner's
// `effective_alpha`, which `SetAlpha 0x76a690` already carries down the tree.
@@ -380,6 +387,18 @@ impl UiScript {
// reference's is a texel block it would grey. No stock caller
// desaturates a solid.
desaturated: data.desaturated && has_path,
// The first mask that has both art and a resolved rect. First, not
// last: see `RegionData::masks` for why the order is the answer and
// why there is one slot. A mask whose own anchors do not resolve is
// walked past rather than taken as an empty rect, which would erase
// the texture it was meant to shape.
mask: data.masks.iter().find_map(|mh| {
let path =
model.region_data.get(mh).and_then(|d| d.texture.clone())?;
let rect = model.region_resolved.get(mh).copied()?;
Some(super::TextureMask { path, rect })
}),
tile: data.tile,
}
};
// A region draws at its owner's scale: only frames carry one (`0x76ac90`).
+14 -2
View File
@@ -295,7 +295,7 @@ pub use trainer::{
pub use types::{
BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject,
FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline,
QuadContent, ScriptValue, TexCoords, UnitPaneSubject,
QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject,
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use uimap::UiMapNode;
@@ -355,7 +355,7 @@ pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
/// Texture-only, each in the client's Texture map (`0x87c128`): `GetVertexColor` though
/// `SetVertexColor` is shared, and `SetGradientAlpha`, not FontString's `SetAlphaGradient`. 1.12
/// has no Texture `SetSize` or `SetRotation` (PlayerModel's, `0x84f1fc`/`0x505f00`).
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [
"SetGradient",
"SetGradientAlpha",
"GetTexture",
@@ -368,6 +368,18 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
"GetTexCoordModifiesRect",
"SetDesaturated",
"GetVertexColor",
// Beyond 1.12, each with its `ALLOWED_BEYOND` row: the mask pair and the later-era
// `SetRotation`/`SetDesaturation`/tile getters and setters a canvas map is written against.
"AddMaskTexture",
"RemoveMaskTexture",
"SetRotation",
"GetRotation",
"SetDesaturation",
"GetDesaturation",
"SetHorizTile",
"GetHorizTile",
"SetVertTile",
"GetVertTile",
];
/// FontString-only: the font, text, justify and shadow block and the string metrics.
+5
View File
@@ -1197,6 +1197,10 @@ pub(crate) struct Model {
/// `RegisterForDrag` buttons per frame, compared case-insensitively. Nothing destroys a frame,
/// so this, `scripts` and `frame_events` are never pruned; a destroy path must prune all three.
pub(crate) drag_registered: HashMap<FrameHandle, HashSet<String>>,
/// `Frame:SetPropagateKeyboardInput` (`compat/classic_api/frame.rs`), per frame; absent is
/// `true`, which is what this client does unconditionally. **Stored, not applied** — 1.12 has
/// no swallow mode for `false` to select — so this exists so the getter answers the setter.
pub(crate) propagate_keyboard: HashMap<FrameHandle, bool>,
/// The drag gesture, armed by a press on a drag-registered frame, started past the threshold.
pub(crate) drag: Option<cursor::DragGesture>,
/// The one `StartMoving()` in flight, the client's `root+0xcfc` slot, apart from `drag` as in
@@ -2198,6 +2202,7 @@ impl Model {
container_autoequips: Vec::new(),
bag_autostores: Vec::new(),
drag_registered: HashMap::new(),
propagate_keyboard: HashMap::new(),
drag: None,
moving: None,
sizing: None,
@@ -293,6 +293,148 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// AddMaskTexture(mask) / RemoveMaskTexture(mask), Texture only, **beyond 1.12** (surface row
// in `script/tests/widget_surface.rs`): the later-era way to shape a texture by another
// region's alpha. 1.12 has no masking at all — its one masked thing is the minimap disc, cut
// engine-side — and nothing can stand in for it, because the whole point is that a
// *rectangular* texture draws as a non-rectangular shape.
//
// It is built rather than refused because a seamless world map is exactly that operation: a
// zone's art is a rectangle of parchment and the map wants it clipped to the zone's coastline,
// so without this every zone draws as an overlapping paper rectangle with its neighbours'
// oceans and labels showing through. OpenMapClassic's own changelog names the symptom ("no
// overlapping parchment rectangles / duplicated sea labels").
//
// The argument is the mask region itself, so the list stores handles and the resolved art and
// rect are read at extract time — a mask that moves or changes art moves the cut with it,
// which is what a panning map needs. A non-region argument is ignored rather than raised on:
// every caller is `if mask then tex:AddMaskTexture(mask) end`, and the masks here come from
// generated media that may simply not ship.
for (name, add) in [("AddMaskTexture", true), ("RemoveMaskTexture", false)] {
m.set(
name,
lua.create_function(move |lua, (this, mask): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let Value::Table(t) = mask else {
return Ok(());
};
let Ok(mh) = region_handle_of(lua, &t) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
if add {
if !d.masks.contains(&mh) {
d.masks.push(mh);
}
} else {
d.masks.retain(|&h| h != mh);
}
Ok(())
})?,
)?;
}
// SetRotation(radians) / GetRotation(), Texture only, **beyond 1.12**: 1.12 registers
// `SetRotation` on PlayerModel alone (`0x84f1fc`), so a Texture there cannot turn. The
// renderer has carried `RegionData::rotation` since the minimap arrow, which is drawn by the
// host; this is the Lua setter for it, and the quad path already negates it for the pass's
// clockwise convention. Counterclockwise-positive, as the later-era method is.
m.set(
"SetRotation",
lua.create_function(|lua, (this, radians): (Table, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// A non-finite angle would rotate the quad's corners to NaN and drop it silently.
let r = radians.filter(|r| r.is_finite()).unwrap_or(0.0);
model.region_data.entry(rh).or_default().rotation = r;
Ok(())
})?,
)?;
m.set(
"GetRotation",
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).map_or(0.0, |d| d.rotation))
})?,
)?;
// SetDesaturation(amount) / GetDesaturation(), Texture only, **beyond 1.12**: the later-era
// *fractional* form of `SetDesaturated` above, 0 full colour and 1 fully grey.
//
// This renderer greys all-or-nothing (one material flag, not a blend weight), so a fraction is
// taken as on past the midpoint. That is a real approximation and it is the honest one
// available: the alternative is refusing the method, and a caller's only use of it is to dim
// art it has already decided to dim — OpenMapClassic desaturates its minimap tiles by a fixed
// 0.5 so the painted map reads over them. The getter answers back what was set, not the
// rounded flag, so a caller reading its own value sees its own number.
m.set(
"SetDesaturation",
lua.create_function(|lua, (this, amount): (Table, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let a = amount
.filter(|a| a.is_finite())
.unwrap_or(0.0)
.clamp(0.0, 1.0);
let d = model.region_data.entry(rh).or_default();
d.desaturation = a;
d.desaturated = a > 0.5;
Ok(())
})?,
)?;
m.set(
"GetDesaturation",
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).map_or(0.0, |d| d.desaturation))
})?,
)?;
// SetHorizTile/SetVertTile and their getters, Texture only, **beyond 1.12**: repeat the art
// across the region at its own texel size instead of stretching it to fit.
//
// 1.12 reaches the same end through `SetTexCoord(0, n, 0, 1)` — UVs past the texture, which is
// the idiom this renderer already keys its wrap mode on (`extract::tiling_axes`) — but it has
// no switch that says "however many times it takes", because that count depends on the art's
// texel size and 1.12's Lua is never told it. So the flag is carried and the host computes the
// count once the art has loaded. An explicit `SetTexCoord` wins, because it names the count.
for (name, vertical) in [("SetHorizTile", false), ("SetVertTile", true)] {
m.set(
name,
lua.create_function(move |lua, (this, on): (Table, Option<bool>)| {
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 on = on.unwrap_or(false);
if vertical {
d.tile.1 = on;
} else {
d.tile.0 = on;
}
Ok(())
})?,
)?;
}
for (name, vertical) in [("GetHorizTile", false), ("GetVertTile", true)] {
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_data.get(&rh).is_some_and(|d| {
if vertical {
d.tile.1
} else {
d.tile.0
}
}))
})?,
)?;
}
// GetTexture (`0x79ba70`), Texture only, answers one value: "Solid Texture" for the colour
// form (`0x835708`), not nil, and a path cut at its last `.` (`0x79baf0`), even a dot in a
// directory name, as the client cuts it.
@@ -734,6 +734,32 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// same reveal gate, published in the shape the canvas framework reads
// (script/compat/classic_api/uimap.rs).
"C_MapExplorationInfo",
// 4.x's cross-realm name shortener. 1.12 has no connected realms and no cross-realm
// names, so this is the identity on the part that matters; the `-Realm` split is still
// applied because a custom realm can hand out a hyphenated name and the caller's whole
// purpose is the short one. Called unguarded on a chat sender (`Map/Pins.lua:286`).
"Ambiguate",
// 2.x's rename of 1.12's own FrameXML `GetDifficultyColor`. Registered as a **forwarder**
// to that function rather than a copy of its table, so the colours are whatever the
// player's own chain says and the two spellings cannot drift.
"GetQuestDifficultyColor",
// 2.0's predicate over 1.12's own modified-click *bindings*: 1.12 has
// `GetModifiedClick`/`SetModifiedClick` and its FrameXML tests the key itself, but
// registers nothing that does both steps. So this composes two verbs this client already
// answers rather than adding state.
"IsModifiedClick",
// ClassicAPI's object pools, the frame and font-string halves beside the texture pool
// already here (`compat/classic_api/pools.rs`): a canvas map pools a frame per pin and a
// FontString per label, and calls both at file scope the way pfUI calls
// `CreateTexturePool`. The mixins and the default resetters are upstream's own names,
// which is what a caller passing `FramePool_HideAndClearAnchors` by name needs.
"CreateFramePool",
"FramePoolMixin",
"FramePool_Hide",
"FramePool_HideAndClearAnchors",
"CreateFontStringPool",
"FontStringPoolMixin",
"FontStringPool_HideAndClearAnchors",
// The two argument *shapes* `C_Map`'s uiMapID members take. 1.12 passes coordinates as
// loose numbers and has no vector or map-point object; a caller cannot call
// `C_Map.SetUserWaypoint` or `C_Map.GetWorldPosFromMapPos` without them, and every call
+224 -7
View File
@@ -856,13 +856,18 @@ fn the_two_region_leaves_answer_their_own_maps() {
assert!(has(&s, "Tex", m), "a Texture answers {m}");
assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}");
}
// `SetRotation` is 1.12's PlayerModel verb (`0x84f1fc`/`0x505f00`), in neither leaf map.
for leaf in ["Tex", "Str"] {
assert!(
!has(&s, leaf, "SetRotation"),
"SetRotation is not a region method"
);
}
// `SetRotation` is 1.12's PlayerModel verb (`0x84f1fc`/`0x505f00`) and no FontString's; the
// later-era Texture spelling is built and carries its own `ALLOWED_BEYOND` row
// (`compat/classic_api/frame.rs`'s surface list), so the split, not the absence, is the rule
// here: a Texture may turn and a FontString may not.
assert!(
has(&s, "Tex", "SetRotation"),
"a Texture answers SetRotation"
);
assert!(
!has(&s, "Str", "SetRotation"),
"a FontString must NOT answer SetRotation"
);
// FontString-only.
for m in [
"SetText",
@@ -1530,3 +1535,215 @@ fn the_visibility_reading_includes_named_regions_and_their_owners_state() {
"an owner that is hidden hides its regions"
);
}
/// A mask shapes the texture it was added to and draws nothing of its own.
///
/// Both halves matter and both were easy to get wrong. The mask's *rect* is what it covers, so it
/// has to stay in the layout — which is why it is skipped at extract rather than marked hidden —
/// and the masked quad has to carry the mask's own resolved rect, not the texture's, or a panning
/// tile would drag its cut-out along with it.
#[test]
fn a_mask_texture_shapes_its_subject_and_never_draws_itself() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "MaskOwner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(200) f:SetHeight(100)
art = f:CreateTexture("MaskArt", "ARTWORK")
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
art:SetAllPoints()
cut = f:CreateMaskTexture("MaskCut", "ARTWORK")
cut:SetTexture("Interface\\AddOns\\Thing\\land")
cut:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 20)
cut:SetWidth(50) cut:SetHeight(40)
"#,
)
.unwrap();
s.resolve();
let textures = |s: &UiScript| -> Vec<(Option<String>, Option<crate::script::TextureMask>)> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path, mask, .. } => Some((path.clone(), mask.clone())),
_ => None,
})
.collect()
};
let before = textures(&s);
assert_eq!(
before.len(),
1,
"the mask is not drawn, so only the art emits a quad: {before:?}"
);
assert!(before[0].1.is_none(), "and it is unmasked until asked");
s.run("art:AddMaskTexture(cut)").unwrap();
let after = textures(&s);
assert_eq!(after.len(), 1, "still one quad");
let mask = after[0].1.clone().expect("the art now carries the mask");
assert_eq!(mask.path, "Interface\\AddOns\\Thing\\land");
// The mask's own rect, which is a quarter the width of the art it shapes.
assert!(
(mask.rect.left - 10.0).abs() < 0.01
&& (mask.rect.bottom - 20.0).abs() < 0.01
&& (mask.rect.right - 60.0).abs() < 0.01
&& (mask.rect.top - 60.0).abs() < 0.01,
"the mask's resolved rect, not the texture's: {:?}",
mask.rect
);
s.run("art:RemoveMaskTexture(cut)").unwrap();
assert!(
textures(&s)[0].1.is_none(),
"removing it takes the cut off again"
);
}
/// `AddMaskTexture` keeps the list in call order and the **first** is the one applied, because
/// that is the one that shapes the subject — a stitched map adds the zone's land mask first and
/// its refinements after. A second add of the same mask is not a second entry.
#[test]
fn the_first_mask_added_is_the_one_applied() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "TwoMaskOwner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(200) f:SetHeight(100)
art = f:CreateTexture(nil, "ARTWORK")
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
art:SetAllPoints()
land = f:CreateMaskTexture(nil, "ARTWORK")
land:SetTexture("land") land:SetAllPoints()
hole = f:CreateMaskTexture(nil, "ARTWORK")
hole:SetTexture("hole") hole:SetAllPoints()
art:AddMaskTexture(land)
art:AddMaskTexture(hole)
art:AddMaskTexture(land)
"#,
)
.unwrap();
s.resolve();
let applied = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(_),
mask: Some(m),
..
} => Some(m.path.clone()),
_ => None,
})
.expect("the masked art");
assert_eq!(applied, "land", "first added wins");
// Taking the first off promotes the second, which is what a caller swapping a mask means.
s.run("art:RemoveMaskTexture(land)").unwrap();
let applied = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(_),
mask: Some(m),
..
} => Some(m.path.clone()),
_ => None,
})
.expect("still masked");
assert_eq!(applied, "hole");
}
/// A mask whose own anchors never resolve is walked past rather than taken as an empty rect: an
/// empty mask erases the texture it was meant to shape, which looks like the art failing to load.
#[test]
fn an_unresolved_mask_is_walked_past_not_applied_as_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "BadMaskOwner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(200) f:SetHeight(100)
art = f:CreateTexture(nil, "ARTWORK")
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
art:SetAllPoints()
-- Art but no anchors: a region nobody placed, which never resolves.
orphan = f:CreateMaskTexture(nil, "ARTWORK")
orphan:SetTexture("orphan")
orphan:ClearAllPoints()
good = f:CreateMaskTexture(nil, "ARTWORK")
good:SetTexture("good") good:SetAllPoints()
art:AddMaskTexture(orphan)
art:AddMaskTexture(good)
"#,
)
.unwrap();
s.resolve();
let applied = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(_),
mask,
..
} => Some(mask.clone().map(|m| m.path)),
_ => None,
})
.expect("the art quad");
assert_eq!(
applied.as_deref(),
Some("good"),
"the unplaced mask is skipped and the next one applies"
);
}
/// The later-era `SetRotation`/`SetDesaturation` and the tile pair round-trip, and the fractional
/// desaturation rounds onto the one material flag this renderer has.
#[test]
fn the_later_era_texture_dials_round_trip() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "DialOwner")
t = f:CreateTexture("DialArt", "ARTWORK")
t:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
"#,
)
.unwrap();
assert_eq!(s.eval::<f64>("return t:GetRotation()").unwrap(), 0.0);
s.run("t:SetRotation(1.5)").unwrap();
assert!((s.eval::<f64>("return t:GetRotation()").unwrap() - 1.5).abs() < 1e-6);
// A non-finite angle would send the quad's corners to NaN and drop it with no error.
s.run("t:SetRotation(0/0)").unwrap();
assert_eq!(s.eval::<f64>("return t:GetRotation()").unwrap(), 0.0);
assert_eq!(s.eval::<f64>("return t:GetDesaturation()").unwrap(), 0.0);
s.run("t:SetDesaturation(0.4)").unwrap();
assert!(
(s.eval::<f64>("return t:GetDesaturation()").unwrap() - 0.4).abs() < 1e-6,
"the getter answers the caller's own fraction"
);
assert!(
!desaturated(&s, "DialArt"),
"…and below the midpoint the one flag stays off"
);
s.run("t:SetDesaturation(0.8)").unwrap();
assert!(desaturated(&s, "DialArt"), "past it, on");
assert!(!s.eval::<bool>("return t:GetHorizTile()").unwrap());
assert!(!s.eval::<bool>("return t:GetVertTile()").unwrap());
s.run("t:SetHorizTile(true)").unwrap();
assert!(s.eval::<bool>("return t:GetHorizTile()").unwrap());
assert!(
!s.eval::<bool>("return t:GetVertTile()").unwrap(),
"one axis at a time"
);
}
@@ -405,6 +405,112 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[
the two spellings cannot drift. Ace3 calls it unguarded on every button it builds \
(AceConfigDialog-3.0.lua:586)",
),
(
"AddMaskTexture",
"Texture",
"The map canvas: 1.12 has no texture masking at all, and nothing can stand in for it \
because the point is that a RECTANGULAR texture draws as a non-rectangular shape. A \
seamless world map is that operation and nothing else — a zone's parchment cut to the \
zone's coastline — so without it every zone draws as a paper rectangle over its \
neighbours' oceans and labels. Called unguarded where it is used: Map/Close.lua:162",
),
(
"RemoveMaskTexture",
"Texture",
"The other half of AddMaskTexture, so a caller that swaps a mask can take the old one \
off; without it a re-masked tile keeps the first cut forever",
),
(
"CreateMaskTexture",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"The region AddMaskTexture takes: a Texture that shapes others and never draws itself. \
Built over the stock CreateTexture on the same table, so a mask is an ordinary Texture \
in every other respect. Map/Close.lua:159 builds one before any texture can ask for it",
),
(
"SetRotation",
"Texture",
"1.12 registers SetRotation on PlayerModel alone (0x84f1fc), so a Texture there cannot \
turn. The renderer has carried the angle since the minimap arrow, which the host draws; \
this is the Lua setter for it. The map's own player arrow and its compass rail need it",
),
(
"GetRotation",
"Texture",
"The read half of SetRotation, so the pair round-trips; 0 by default",
),
(
"SetDesaturation",
"Texture",
"The later-era FRACTIONAL form of 1.12's own SetDesaturated. This renderer greys \
all-or-nothing (one material flag, not a blend weight), so a fraction is taken as on \
past the midpoint — an approximation, and the honest one available, since the only use \
of it in the corpus is dimming art the caller has already decided to dim \
(Map/Close.lua desaturates minimap tiles by a fixed amount so the painted map reads \
over them)",
),
(
"GetDesaturation",
"Texture",
"The read half, answering the caller's own fraction rather than the rounded flag",
),
(
"SetHorizTile",
"Texture",
"Repeat the art across the region at its own texel size. 1.12 reaches the same end with \
SetTexCoord(0, n, 0, 1) — UVs past the texture, which is the idiom this renderer keys \
its wrap mode on — but has no switch meaning 'however many times it takes', because \
that count is rect/texels and 1.12's Lua is never told the texel size. Applied: the \
host computes the count once the art has loaded, and an explicit SetTexCoord wins",
),
(
"SetVertTile",
"Texture",
"As SetHorizTile, for the other axis",
),
(
"GetHorizTile",
"Texture",
"The read half of SetHorizTile; false by default",
),
(
"GetVertTile",
"Texture",
"The read half of SetVertTile; false by default",
),
(
"HasFocus",
"EditBox",
"1.12's EditBox table has SetFocus and ClearFocus and no getter at all, so an addon of \
that era keeps its own flag. The fact is already tracked here (Model::focused_editbox, \
the client's own DAT_00cf4dc8) and was simply never published. Called unguarded inside \
an OnEditFocusLost callback (Map/Nav.lua:374), so a nil method raised every time a \
search box lost focus",
),
(
"SetCursorPosition",
"EditBox",
"1.12 has no cursor setter at all — the caret moves only by typing, clicking or \
HighlightText. Lands through HighlightText's own clamp and UTF-8 snap with the selection \
collapsed, so there is one definition of where a caret may sit. Map/News.lua:282 calls \
it to scroll a long read-only link box back to its start",
),
(
"SetPropagateKeyboardInput",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"4.x's switch for whether a frame with an OnKeyDown handler lets the key reach the next \
frame and the binding system. STORED, NOT APPLIED, and the reason is that 1.12's answer \
is always-propagate: its key routing hands the key to the binding system regardless of \
what a handler did, so there is no swallow mode for false to select. Refusing it costs a \
caller its whole key path — Map/Sketch.lua:579 arms a catcher frame and flips the flag \
inside its own handler, so a nil method raises once per keypress",
),
(
"GetPropagateKeyboardInput",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"The read half, so the pair round-trips; true by default, which is what this client does \
unconditionally",
),
(
"CanWordWrap",
"FontString",
+49 -1
View File
@@ -168,6 +168,14 @@ pub enum QuadContent {
/// `SetDesaturated`: the texel draws as its luminance, then is tinted by `color`, since
/// `SetItemButtonDesaturated` passes a dim tint with the flag.
desaturated: bool,
/// `Texture:AddMaskTexture`'s first mask, resolved: the mask art and the screen rect it
/// covers. `None` is unmasked, which every 1.12 texture is.
mask: Option<TextureMask>,
/// `SetHorizTile`/`SetVertTile`: sample with repeat addressing across the region at the
/// art's own texel size. Carried as a flag rather than as UVs because the repeat count is
/// `rect / texels` and the texel size is only known once the art has loaded, which is in
/// the host's half.
tile: (bool, bool),
},
/// A `ColorSelect`'s `<ColorWheelTexture>` disc, generated: the pixel at normalised offset
/// `(nx, ny)` from the centre is `HSV(atan2(ny, nx)·180/π + 180, min(|n|, 1), 1)`, inverting
@@ -369,6 +377,22 @@ pub struct ExtractedQuad {
pub scale: f32,
}
/// A resolved mask over a Texture quad (`Texture:AddMaskTexture`): the mask region's own art and
/// the rect it occupies on screen, in the same y-up UI space as [`ExtractedQuad::rect`].
///
/// The mask is **screen-anchored**, not UV-anchored, because that is what the method means: the
/// mask is another region with its own anchors, and the masked texture keeps only the fragments
/// the mask's alpha covers at that place. A tile panning under a fixed cut-out masks correctly,
/// which is the whole reason a stitched map uses one — a zone's rectangular art clipped to the
/// zone's coastline.
#[derive(Clone, Debug, PartialEq)]
pub struct TextureMask {
/// The mask art's path; its **alpha** is the coverage.
pub path: String,
/// The mask's resolved rect, y-up UI space.
pub rect: crate::layout::Rect,
}
/// A two-stop linear gradient (`SetGradientAlpha`/`SetGradient`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gradient {
@@ -438,6 +462,10 @@ pub(crate) struct RegionData {
pub(crate) anchors: Vec<Anchor>,
/// `SetDesaturated` (`0x79c1e0`): the renderer greys the texel, so the binding reports support.
pub(crate) desaturated: bool,
/// The later-era `SetDesaturation`'s fraction, which [`Self::desaturated`] rounds for the one
/// material flag this renderer has. Kept so the getter answers the caller's own number rather
/// than the rounded flag.
pub(crate) desaturation: f32,
/// `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`), `None` on. Stored, not applied:
/// the reference's flag `0x40` drives a mid-word wrap and the ellipsis fit count (`0x771ec0`).
pub(crate) non_space_wrap: Option<bool>,
@@ -466,8 +494,28 @@ pub(crate) struct RegionData {
/// `0x79c080`). Stored, not applied: there a set flag makes `SetTexCoord` re-derive the rect
/// from the UV quad (`0x770462`); no stock or corpus code sets it.
pub(crate) tex_coord_modifies_rect: bool,
/// Rotation about the centre, radians counterclockwise; always 0: 1.12 has no Texture setter.
/// Rotation about the centre, radians counterclockwise. 1.12 has no Texture setter; the
/// later-era `Texture:SetRotation` writes it (`compat/classic_api/frame.rs`'s surface row).
pub(crate) rotation: f32,
/// `AddMaskTexture`'s masks in call order, `RemoveMaskTexture`'s removals taken out.
///
/// **Only the first is drawn.** The render pass carries one mask slot per quad
/// ([`crate::script::TextureMask`]), and widening it is a second texture binding and a second
/// uniform on every UI quad in the client. The list is kept whole anyway, because the methods
/// have to round-trip for a caller that adds and removes, and because the *order* decides
/// which one wins — first added, which for a stitched map is the zone's land mask, the one
/// that shapes the coastline. A second mask is recorded and not applied.
pub(crate) masks: Vec<crate::widget::RegionHandle>,
/// A mask texture (`Frame:CreateMaskTexture`), which shapes other regions and never draws
/// itself. 1.12 has no such region kind, so this is a flag on an ordinary Texture rather than
/// a `RegionKind`: every other method on it behaves exactly as a Texture's, which is what the
/// callers do with it — they set its art, size and anchors and then hand it to
/// `AddMaskTexture`.
pub(crate) is_mask: bool,
/// `SetHorizTile`/`SetVertTile` (`GetHorizTile`/`GetVertTile` read them back). Applied: the
/// host repeats the art across the region at its own texel size when no explicit `SetTexCoord`
/// says otherwise.
pub(crate) tile: (bool, bool),
/// The font object this FontString inherits (`FONTINSTANCE+0x028`, a FontString's `+0xd0`), a
/// live link: its paint is copied below, and
/// [`script::font::propagate`](super::font::propagate) repaints on a change.
File diff suppressed because one or more lines are too long