From b49856c5cbccffbb0c59faf6ffda9b85d81716a8 Mon Sep 17 00:00:00 2001 From: grom Date: Sun, 1 Nov 2026 21:15:55 -0700 Subject: [PATCH] ui: texture masks, the later-era texture dials, and the pools a canvas map needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../benilla-app/src/ui_script/extract/mod.rs | 132 +++++++++- .../src/script/compat/classic_api/frame.rs | 98 ++++++++ .../src/script/compat/classic_api/misc.rs | 91 +++++++ .../src/script/compat/classic_api/pools.rs | 72 +++++- crates/benilla-ui/src/script/editbox.rs | 10 +- crates/benilla-ui/src/script/extract.rs | 19 ++ crates/benilla-ui/src/script/mod.rs | 16 +- crates/benilla-ui/src/script/model.rs | 5 + crates/benilla-ui/src/script/region/paint.rs | 142 +++++++++++ .../src/script/tests/reference_surface.rs | 26 ++ crates/benilla-ui/src/script/tests/regions.rs | 231 +++++++++++++++++- .../src/script/tests/widget_surface.rs | 106 ++++++++ crates/benilla-ui/src/script/types.rs | 50 +++- docs/ADDON_COMPAT.md | 1 + 14 files changed, 983 insertions(+), 16 deletions(-) diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index d5c0edef..456f6d52 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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::(); + app.init_resource::(); + app.init_resource::>(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::