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();