ui: MOD multiplies, and a gradient is not an alternative to a fill

Cinematic's colour tint painted the whole screen opaque white for as long as its
CineMode ran: `srgb(255,255,255)` across the world with the UI still drawn on top
of it, 16.8 s of it in grom's 2026-11-02 recording, starting the instant he picked
a profile. Three faults on the one path from the addon's call to the pixel, each
of them enough on its own.

`SetBlendMode("MOD")` parsed, answered `GetBlendMode` and then drew as straight
alpha. One pipeline served BLEND and ADD — the shader emitting `(rgb·a, a)` or
`(rgb·a, 0)` into a single `(One, OneMinusSrcAlpha)` state — because no scalar
source alpha can stand in for a per-channel destination factor, and the mode had
no caller worth a second pipeline. It had one: nine of the addon's ten tint
presets are MOD (`Tint.lua:21`), and a MOD colour is built to be multiplied —
`1 − (1 − c)·strength` at **alpha 1**, white at strength 0 — so drawn with alpha
instead it is an opaque near-white quad over UIParent. MOD now keys its own
pipeline, `(Dst, Zero)` with the destination's own alpha kept, off the same
`0x70c190` table the world's Mod batches already read; the shader returns the
multiplier un-premultiplied, since neither alpha appears in that formula, and runs
no longer merge across the flag.

The extract read `fill.or_else(|| gradient.map(midpoint))`, so a region that set
both drew the fill and dropped the gradient. They are not alternatives:
`<Gradient>` writes the four vertex colours (`+0xb8`, `0x77304d`), which
`loader/regions.rs` already reads as "unlike `<Color>` it tints the art". The tint
is exactly that shape — `SetColorTexture(1, 1, 1, 1)` once, re-gradiented a few
times a second — and so is its vignette, whose four edges were drawing as opaque
white bars.

And `SetGradient` took only 1.12's six loose numbers. This client answers
`CreateColor`, so an addon that feature-detects takes retail's colour-object form
and never tries the numbers; the two objects coerced to six zeros through
`as_f32`, the call **succeeded**, and the `pcall` that was the feature test
(`Tint.lua:546`) reported it understood. Both spellings now read a colour object
when the first stop is a table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-02 10:00:44 -07:00
co-authored by Claude Opus 5
parent 33d0868009
commit aa2ee515aa
8 changed files with 258 additions and 19 deletions
@@ -78,6 +78,9 @@ fn vertex(vertex: Vertex) -> VertexOutput {
// Set only for a booth bake (portrait, paper doll, dressing room), already premultiplied because
// its additive particles add light with no coverage; every other texture is straight alpha.
@group(2) @binding(8) var<uniform> premultiplied: u32;
// EGxBlend 4 (`MOD`): the pipeline's blend state is `(DST_COLOR, ZERO)`, so the fragment is the
// multiplier itself. Neither alpha appears in that formula, so neither appears below.
@group(2) @binding(12) var<uniform> modulate: u32;
// Alpha-test reference, <= 0 disables; only the WMO-interior minimap tiles, which the reference
// draws under EGxBlend 1: no blend, `glAlphaFunc(GL_GEQUAL, 224/255)` (`0x85ad20[1]`) on
// `texel.a × frameAlpha`. The screen mask stays out: the reference cuts it at the offscreen blit.
@@ -151,6 +154,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Premultiply in gamma, not by the hardware `SrcAlpha`. A booth bake takes `k` alone: `t.a`
// again would zero its additive light over empty space.
let weight = select(a, k, premultiplied != 0u);
if modulate != 0u {
return vec4<f32>(rgb, 1.0);
}
if additive != 0u {
return vec4<f32>(rgb * weight, 0.0);
}
+58 -5
View File
@@ -77,6 +77,10 @@ pub(crate) struct UiQuad {
pub color: [f32; 4],
/// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`) instead of straight alpha.
pub additive: bool,
/// WoW `MOD` blend (EGxBlend 4, `glBlendFunc(GL_DST_COLOR, GL_ZERO)`): the quad multiplies
/// what is under it and its alpha plays no part. Its own pipeline, not a shader path: no
/// scalar source alpha can stand in for a per-channel destination factor.
pub modulate: bool,
/// Mask to the inscribed circle: the live unit portrait.
pub circular: bool,
/// Draw the texel's luminance (`Texture:SetDesaturated(1)`), folded in gamma bytes before the
@@ -130,6 +134,7 @@ impl Default for UiQuad {
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, 1.0],
additive: false,
modulate: false,
circular: false,
desaturated: false,
premultiplied: false,
@@ -205,9 +210,15 @@ struct UiWhiteTexture(Handle<Image>);
/// premultiplied-alpha pipeline (`ui_quad.wgsl`). It holds no colour, so it is its identity key
/// alone and a steady frame re-prepares none.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
#[bind_group_data(UiQuadKey)]
pub(crate) struct UiQuadMaterial {
#[uniform(0)]
additive: u32,
/// The `MOD` blend. A **specialization key** as well as a uniform: it picks the pipeline's
/// blend state in [`Self::specialize`], because a per-channel destination factor is not
/// something the shader can reach from inside the one premultiplied state.
#[uniform(12)]
modulate: u32,
#[texture(1)]
#[sampler(2)]
texture: Option<Handle<Image>>,
@@ -233,6 +244,21 @@ pub(crate) struct UiQuadMaterial {
uv_clamp: Vec4,
}
/// The one axis the UI pipeline specializes on: `MOD` needs its own blend state, and a blend state
/// is a pipeline. Everything else about a quad is a uniform the one pipeline reads.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiQuadKey {
modulate: bool,
}
impl From<&UiQuadMaterial> for UiQuadKey {
fn from(m: &UiQuadMaterial) -> Self {
Self {
modulate: m.modulate != 0,
}
}
}
/// A run's one colour for the batch entity's [`MeshTag`], unpacked by `ui_quad.wgsl`'s vertex
/// stage, so a colour pulse is a component write, never a mesh or material write. One byte per
/// channel, `×255 + 0.5`, as the reference packs a vertex colour (`CImVector`; `SetVertexColor`
@@ -255,6 +281,7 @@ impl UiQuadMaterial {
pub(crate) fn interior_tile(texture: Handle<Image>, alpha_ref: f32) -> Self {
Self {
additive: 0,
modulate: 0,
texture: Some(texture),
circular: 0,
desaturate: 0,
@@ -294,7 +321,7 @@ impl Material2d for UiQuadMaterial {
fn specialize(
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: Material2dKey<Self>,
key: Material2dKey<Self>,
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
if let Some(fragment) = descriptor.fragment.as_mut() {
if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) {
@@ -306,9 +333,29 @@ impl Material2d for UiQuadMaterial {
dst_factor: BlendFactor::OneMinusSrcAlpha,
operation: BlendOperation::Add,
};
target.blend = Some(BlendState {
color: premultiplied,
alpha: premultiplied,
target.blend = Some(if key.bind_group_data.modulate {
// EGxBlend 4, `GL_DST_COLOR, GL_ZERO` (`0x70c190`, the same table the model
// materials read): the quad multiplies what is already in the target, in
// gamma, which is the reference's byte math. No source alpha appears in it,
// which is why the shader returns the factor un-premultiplied. The
// destination's own alpha is kept, as it is for the world's Mod batches.
BlendState {
color: BlendComponent {
src_factor: BlendFactor::Dst,
dst_factor: BlendFactor::Zero,
operation: BlendOperation::Add,
},
alpha: BlendComponent {
src_factor: BlendFactor::Zero,
dst_factor: BlendFactor::One,
operation: BlendOperation::Add,
},
}
} else {
BlendState {
color: premultiplied,
alpha: premultiplied,
}
});
}
}
@@ -462,6 +509,7 @@ fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> {
struct Run {
texture: Handle<Image>,
additive: bool,
modulate: bool,
circular: bool,
desaturated: bool,
premultiplied: bool,
@@ -481,6 +529,7 @@ impl Run {
Self {
texture,
additive: q.additive,
modulate: q.modulate,
circular: q.circular,
desaturated: q.desaturated,
premultiplied: q.premultiplied,
@@ -670,6 +719,7 @@ type MatKey = (
bool,
bool,
bool,
bool,
u32,
Option<AssetId<Image>>,
[u32; 4],
@@ -774,7 +824,7 @@ fn rebuild_ui_mesh(
// Only the two resize sites emit this event, so a steady frame does no work here.
if !reshaped.is_empty() {
for (key, material) in &pools.materials {
if reshaped.contains(&key.0) || key.7.is_some_and(|mask| reshaped.contains(&mask)) {
if reshaped.contains(&key.0) || key.8.is_some_and(|mask| reshaped.contains(&mask)) {
// The touch is the point; the value is not wanted.
let _ = stores.1.get_mut(material.id());
}
@@ -927,6 +977,7 @@ fn rebuild_ui_mesh(
let same_run = runs.last().is_some_and(|r| {
(r.texture == texture || one_texture_probe())
&& r.additive == q.additive
&& r.modulate == q.modulate
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.premultiplied == q.premultiplied
@@ -998,6 +1049,7 @@ fn rebuild_ui_mesh(
let key: MatKey = (
run.texture.id(),
run.additive,
run.modulate,
run.circular,
run.desaturated,
run.premultiplied,
@@ -1038,6 +1090,7 @@ fn rebuild_ui_mesh(
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
modulate: u32::from(run.modulate),
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
@@ -1322,6 +1322,7 @@ fn convert_entry(
path,
color,
additive,
modulate,
tex_coords,
circular,
portrait_unit,
@@ -1490,6 +1491,7 @@ fn convert_entry(
uv_clamp,
color,
additive,
modulate,
clip,
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
rotation: -rotation,
@@ -140,6 +140,7 @@ pub(super) fn emit(
uv: UvRect::FULL,
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
additive: false,
modulate: false,
circular: false,
desaturated: false,
premultiplied: false,
@@ -321,6 +322,7 @@ pub(super) fn emit(
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, host.alpha],
additive: false,
modulate: false,
circular: false,
desaturated: false,
premultiplied: false,
+15 -4
View File
@@ -427,7 +427,8 @@ impl UiScript {
a.1.max(b.1) + half,
a.0.max(b.0) + half,
));
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
// A fill and a gradient compose, as at the texture arm below.
let fill = texture_color(data.fill, data.gradient.map(|g| g.midpoint()));
let has_texture = data.texture.is_some() || fill.is_some();
QuadContent::Line {
path: data.texture,
@@ -443,7 +444,16 @@ impl UiScript {
// The draw gate is the texture slot, never the colour (`0x7706e0`: empty
// `+0xcc` emits nothing); a vertex colour alone is a tint. A gradient fills
// the texture slot, so it draws, at its midpoint since a quad has one tint.
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
//
// A fill and a gradient are not alternatives: the gradient writes the four
// vertex colours (`+0xb8`, `0x77304d`), which `loader/regions.rs` already
// reads as "unlike `<Color>` it tints the art", so a region carrying both
// draws their product. Measured: Cinematic's screen tint is a fullscreen
// `SetColorTexture(1, 1, 1, 1)` re-gradiented a few times a second
// (`Tint.lua:567`, `:641`). Letting the fill win painted an opaque white
// quad over UIParent — the whole screen white under the UI — for as long
// as its CineMode ran.
let fill = texture_color(data.fill, data.gradient.map(|g| g.midpoint()));
let has_path = data.texture.is_some();
let has_texture = has_path || fill.is_some();
QuadContent::Texture {
@@ -451,9 +461,10 @@ impl UiScript {
color: has_texture
.then(|| texture_color(fill, data.vertex_color))
.flatten(),
// The renderer acts on ADD only: the other four `alphaMode` values
// answer `GetBlendMode` but draw as straight alpha.
// ADD and MOD each have their own blend state; the remaining three
// `alphaMode` values answer `GetBlendMode` but draw as straight alpha.
additive: data.blend == crate::script::BlendMode::Add,
modulate: data.blend == crate::script::BlendMode::Mod,
tex_coords: data.tex_coords,
circular: data.circular,
portrait_unit: data.portrait_unit,
+44 -10
View File
@@ -73,7 +73,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetGradientAlpha(orientation, r1, g1, b1, a1, r2, g2, b2, a2) and SetGradient without the
// alphas: a two-stop gradient the client generates into the texture slot (`+0xcc`); here it is
// stored whole and drawn as its midpoint. Any orientation but "VERTICAL", in any case, is
// horizontal.
// horizontal. Either name also takes retail's two colour objects in place of the loose
// numbers — see the arm below for why that one is not optional here.
for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] {
m.set(
name,
@@ -88,16 +89,49 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// A missing or non-string orientation is horizontal.
_ => String::new(),
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(|v| as_f32(lua, v)).unwrap_or(0.0);
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
let stops: Vec<Value> = it.collect();
let (start, end) = if let Some(Value::Table(_)) = stops.first() {
// Retail's `SetGradient(orientation, fromColor, toColor)`, the two
// `ColorMixin` form. 1.12 has only the loose numbers — but this client answers
// `CreateColor` (`compat/classic_api/color_mixin.rs`), and an addon that finds
// that global takes the object path and never tries the numbers. Cinematic's
// tint helper does exactly that, inside a `pcall` whose success *is* its
// feature test (`Tint.lua:546`): reading the object here is what makes that
// test honest. Without it each stop coerced to 0 through `as_f32`, the call
// still succeeded, and its fullscreen tint drew flat black.
//
// The object's alpha is used even under the `SetGradient` name, which is what
// retail does: the alpha-less pair is the *numeric* form's shape, not this
// one's. A missing second colour repeats the first, so a one-colour call is a
// flat tint rather than a fade to black.
let read = |v: Option<&Value>| -> mlua::Result<Option<[f32; 4]>> {
let Some(Value::Table(t)) = v else {
return Ok(None);
};
Ok(Some([
as_f32(lua, &t.get::<Value>("r")?),
as_f32(lua, &t.get::<Value>("g")?),
as_f32(lua, &t.get::<Value>("b")?),
match t.get::<Value>("a")? {
Value::Nil => 1.0,
a => as_f32(lua, &a),
},
]))
};
let from = read(stops.first())?.unwrap_or([0.0, 0.0, 0.0, 1.0]);
(from, read(stops.get(1))?.unwrap_or(from))
} else {
// SetGradient has no alpha stops: both ends are opaque.
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for (i, slot) in c.iter_mut().take(n).enumerate() {
*slot = stops.get(i).map(|v| as_f32(lua, v)).unwrap_or(0.0);
}
if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
} else {
// SetGradient has no alpha stops: both ends are opaque.
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
}
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
@@ -823,6 +823,133 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
);
}
/// `SetGradient(orientation, fromColor, toColor)`: retail's two-colour-object form, which this
/// client has to answer because it answers `CreateColor`. An addon that finds that global takes
/// the object path and never tries the loose numbers — Cinematic's tint helper does exactly that,
/// inside a `pcall` whose success *is* its feature test (`Tint.lua:546`). With only the numeric
/// form the stops each coerced to 0 and the tint drew flat black over the whole screen.
#[test]
fn a_gradient_takes_the_modern_colour_objects_this_client_also_answers() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "ObjGradProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
t = f:CreateTexture(nil, "BACKGROUND")
t:SetAllPoints(f)
t:SetTexture(1, 1, 1, 1)
local from, to = CreateColor(0.2, 0.3, 0.4, 0.5), CreateColor(0.2, 0.3, 0.4, 0.1)
-- The addon's own call, pcall and all: its success decides whether it ever tries the
-- number form, so an accepted call must also have been an understood one.
assert(pcall(t.SetGradient, t, "VERTICAL", from, to), "the object form must be accepted")
"#,
)
.unwrap();
s.resolve();
let c = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c),
_ => None,
})
.expect("the tinted quad paints");
let near = |a: f32, b: f32| (a - b).abs() < 1e-6;
assert!(
near(c[0], 0.2) && near(c[1], 0.3) && near(c[2], 0.4) && near(c[3], 0.3),
"the objects' own channels, at the midpoint of their alphas: {c:?}"
);
}
/// A colour fill and a gradient compose rather than shadow each other: the gradient writes the
/// vertex colours (`+0xb8`), so the white an addon lays down first is the texel the gradient
/// tints, not a colour that outranks it.
///
/// Cinematic's screen tint is this exact shape — a fullscreen `SetColorTexture(1, 1, 1, 1)`
/// re-gradiented a few times a second (`Tint.lua:567`, `:641`). With the fill winning it painted
/// an opaque white quad over UIParent: a white screen with the UI still drawn on top of it, for
/// as long as its CineMode ran.
#[test]
fn a_colour_fill_under_a_gradient_draws_their_product() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "TintProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
t = f:CreateTexture(nil, "BACKGROUND")
t:SetAllPoints(f)
-- Cinematic's own pair, in its own order.
t:SetTexture(1, 1, 1, 1)
t:SetGradientAlpha("VERTICAL", 0.2, 0.3, 0.4, 0.5, 0.2, 0.3, 0.4, 0.1)
"#,
)
.unwrap();
s.resolve();
let c = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c),
_ => None,
})
.expect("the tint quad paints");
let near = |a: f32, b: f32| (a - b).abs() < 1e-6;
assert!(
near(c[0], 0.2) && near(c[1], 0.3) && near(c[2], 0.4) && near(c[3], 0.3),
"white times the gradient is the gradient, at the midpoint of its stops: {c:?}"
);
}
/// `SetBlendMode("MOD")` reaches the quad as its own flag, not as straight alpha. EGxBlend 4 is
/// `GL_DST_COLOR, GL_ZERO`, a per-channel multiply of what is already drawn, and nothing about a
/// straight-alpha quad stands in for it: a fullscreen MOD wash drawn with alpha instead covers
/// the screen in its own colour instead of shading what is under it. Cinematic's colour tint is
/// one of those (`Tint.lua:21`: nine of its ten presets are MOD).
#[test]
fn the_mod_blend_reaches_the_quad_as_its_own_flag() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "ModProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
mod_tex = f:CreateTexture(nil, "BACKGROUND")
mod_tex:SetAllPoints(f)
mod_tex:SetTexture(1, 0.9, 0.8, 1)
mod_tex:SetBlendMode("MOD")
add_tex = f:CreateTexture(nil, "ARTWORK")
add_tex:SetAllPoints(f)
add_tex:SetTexture(1, 1, 1, 1)
add_tex:SetBlendMode("ADD")
"#,
)
.unwrap();
s.resolve();
let flags: Vec<(bool, bool)> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
crate::script::QuadContent::Texture {
additive, modulate, ..
} => Some((*additive, *modulate)),
_ => None,
})
.collect();
assert!(
flags.contains(&(false, true)),
"the MOD texture must carry modulate and not additive: {flags:?}"
);
assert!(
flags.contains(&(true, false)),
"and ADD must still be additive alone: {flags:?}"
);
}
/// Texture's map (`0x87c128`, 22 entries) and FontString's (`0xcf5400`, 32) each answer their own
/// verbs, then fall back to the Region map and stop there.
#[test]
+4
View File
@@ -154,6 +154,10 @@ pub enum QuadContent {
color: Option<[f32; 4]>,
/// The `ADD` blend instead of straight alpha.
additive: bool,
/// The `MOD` blend (`GL_DST_COLOR, GL_ZERO`): the quad multiplies what is already there
/// instead of covering it, and its alpha plays no part. Carried apart from `additive`
/// because the two are different pipelines, not two shader paths through one.
modulate: bool,
/// `None` is the full texture.
tex_coords: Option<TexCoords>,
/// A portrait, masked to the inscribed circle; false with a `portrait_unit` is the square