Textures load, answer and blend as 1.12.1's do (#1902)

Texture paint answered and drew unlike 1.12.1: `SetTexture("")` and non-string names answered nil, a missing file cleared desaturation, `SetTexCoord()` and an unknown `SetBlendMode` passed quietly, a failing bar path still installed a bar, and DISABLE, ALPHAKEY and MOD drew as BLEND.

1.12.1's load (`0x770200`) writes the shader slot only for a file that loads (`0x7702c0`); the binding answers 1 for a null name (`0x79bdae`). The submit draws a coloured DISABLE or ALPHAKEY texture as BLEND (`0x76fb82`); ALPHAKEY alpha-tests at 224/255 and MOD multiplies the destination (DESTCOLOR, ZERO, `0x80a1fc`).

Now one texture load serves every setter, the usage lines raise, the shader draws each mode with a MOD pipeline of its own, and a new test validates the UI shader with naga. The stock UI draws as before.

Fixes #1425
Fixes #1864
Fixes #1863
Fixes #1521
Fixes #1556
Fixes #1550
Fixes #1559
Fixes #1555

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-18 15:07:34 +07:00
committed by GitHub
parent ed630ffb78
commit dd61ea731e
26 changed files with 1010 additions and 364 deletions
Generated
+1
View File
@@ -522,6 +522,7 @@ dependencies = [
"kira",
"libc",
"mint",
"naga",
"num-bigint",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
+8
View File
@@ -124,3 +124,11 @@ windows-sys = { version = "0.59", features = [
benilla-srp = { path = "../benilla-srp" }
num-bigint = "0.4.6"
sha1.workspace = true
# The shader translation test writes the backend this platform renders with, through the naga
# bevy already builds (`shaders.rs`).
[target.'cfg(target_os = "macos")'.dev-dependencies]
naga = { version = "27.0.3", features = ["msl-out"] }
[target.'cfg(not(target_os = "macos"))'.dev-dependencies]
naga = { version = "27.0.3", features = ["spv-out"] }
+15 -7
View File
@@ -586,17 +586,25 @@ fn despawn_rigs(commands: &mut Commands, rigs: &Query<(Entity, Option<&ChildOf>)
}
}
/// One invisible HUD quad per warm frame, so its batch-mesh pipeline (POSITION + UV_0 + COLOR)
/// compiles under the cover. The minimap composite's `Rectangle` layout is a rig in [`menagerie`].
/// Invisible HUD quads per warm frame, so the batch-mesh pipelines (POSITION + UV_0 + COLOR)
/// compile under the cover: the premultiplied one every mode but `MOD` draws through, and
/// `MOD`'s multiply ([`crate::ui_pass::UiQuadKey`]), whose zero-alpha texel discards. The minimap
/// composite's `Rectangle` layout is a rig in [`menagerie`].
fn warm_ui_quad_lane(warm: Res<WarmPass>, mut quads: ResMut<crate::ui_pass::UiQuads>) {
if warm.spawned_at.is_none() || warm.done {
return;
}
quads.overlays.push(crate::ui_pass::UiQuad {
rect: Rect::new(0.0, 0.0, 1.0, 1.0),
color: [0.0, 0.0, 0.0, 0.0],
..default()
});
for blend in [
benilla_ui::script::BlendMode::Blend,
benilla_ui::script::BlendMode::Mod,
] {
quads.overlays.push(crate::ui_pass::UiQuad {
rect: Rect::new(0.0, 0.0, 1.0, 1.0),
color: [0.0, 0.0, 0.0, 0.0],
blend,
..default()
});
}
}
/// Push one degenerate draw per reachable [`EffectPipelineKey`] through the production stream,
+112
View File
@@ -48,4 +48,116 @@ mod tests {
}
assert_eq!(found, 5, "the game's shader set changed size");
}
/// Every `.wgsl` file under `dir` of the embedded source, as the asset server would serve it.
fn embedded_shaders(
source: &bevy::asset::io::AssetSource,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) {
use bevy::tasks::futures_lite::StreamExt;
let reader = source.reader();
let entries: Vec<std::path::PathBuf> = bevy::tasks::block_on(async {
match reader.read_directory(dir).await {
Ok(stream) => stream.collect().await,
Err(_) => Vec::new(),
}
});
for path in entries {
if bevy::tasks::block_on(reader.is_directory(&path)).unwrap_or(false) {
embedded_shaders(source, &path, out);
} else if path.extension().is_some_and(|e| e == "wgsl") {
let text = bevy::tasks::block_on(async {
let mut bytes = Vec::new();
let mut r = reader.read(&path).await.ok()?;
r.read_to_end(&mut bytes).await.ok()?;
String::from_utf8(bytes).ok()
});
if let Some(text) = text {
out.push((path.to_string_lossy().into_owned(), text));
}
}
}
}
/// No gate draws a frame, so `ui_quad.wgsl` is composed here as the pipeline cache composes it:
/// bevy's `ShaderCache` over every embedded shader library, with the defs the 2D mesh pipeline
/// gives each of the two vertex layouts it draws (the batch mesh and the minimap's tile quad).
/// The module must validate and translate for the backend this platform renders with.
#[test]
#[expect(
clippy::result_large_err,
reason = "bevy's `ShaderCache` fixes the module loader's `PipelineCacheError` return"
)]
fn the_ui_quad_shader_composes_and_translates() {
use bevy::shader::{Shader, ShaderCache, ShaderCacheSource, ShaderDefVal};
let app = crate::game_plugins::schedule_tests::headless_client();
let server = app.world().resource::<AssetServer>();
let source = server.get_source(AssetSourceId::from("embedded")).unwrap();
let mut files = Vec::new();
embedded_shaders(source, std::path::Path::new(""), &mut files);
let mut cache: ShaderCache<naga::Module, ()> = ShaderCache::new(
bevy::render::render_resource::WgpuFeatures::empty(),
bevy::render::render_resource::DownlevelFlags::all(),
|_, source, _| match source {
ShaderCacheSource::Naga(module) => Ok(module),
_ => panic!("a WGSL shader composes to a naga module"),
},
);
let mut ui_quad = None;
for (i, (path, text)) in files.into_iter().enumerate() {
let id = AssetId::<Shader>::Uuid {
uuid: bevy::asset::uuid::Uuid::from_u128(i as u128 + 1),
};
if path == "benilla_app/shaders/ui_quad.wgsl" {
ui_quad = Some(id);
}
cache.set_shader(id, Shader::from_wgsl(text, format!("embedded://{path}")));
}
let ui_quad = ui_quad.expect("ui_quad.wgsl is embedded");
let storage =
bevy::render::settings::WgpuLimits::default().max_storage_buffers_per_shader_stage;
for layout in [
["VERTEX_POSITIONS", "VERTEX_UVS", "VERTEX_COLORS"],
["VERTEX_POSITIONS", "VERTEX_NORMALS", "VERTEX_UVS"],
] {
let mut defs: Vec<ShaderDefVal> = layout.iter().map(|&d| d.into()).collect();
defs.push(ShaderDefVal::UInt("MATERIAL_BIND_GROUP".into(), 2));
defs.push(ShaderDefVal::UInt(
"AVAILABLE_STORAGE_BUFFER_BINDINGS".into(),
storage,
));
let module = cache
.get(&(), 0, ui_quad, &defs)
.unwrap_or_else(|e| panic!("{layout:?}: ui_quad.wgsl does not compose: {e:?}"));
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::default(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{layout:?}: ui_quad.wgsl does not validate: {e:?}"));
#[cfg(target_os = "macos")]
naga::back::msl::write_string(
&module,
&info,
// wgpu's Metal backend writes at the device's MSL 2.x; 1.0, the default, lacks
// the instance index every 2D mesh shader reads.
&naga::back::msl::Options {
lang_version: (2, 4),
..Default::default()
},
&naga::back::msl::PipelineOptions::default(),
)
.unwrap_or_else(|e| {
panic!("{layout:?}: ui_quad.wgsl does not translate to MSL: {e:?}")
});
#[cfg(not(target_os = "macos"))]
naga::back::spv::write_vec(&module, &info, &naga::back::spv::Options::default(), None)
.unwrap_or_else(|e| {
panic!("{layout:?}: ui_quad.wgsl does not translate to SPIR-V: {e:?}")
});
}
}
}
+47 -11
View File
@@ -1,8 +1,13 @@
// The player-UI quad material. The reference draws the UI fixed-function into an 8-bit
// backbuffer, so the fragment tints and premultiplies on gamma bytes and outputs raw gamma for the
// `(One, OneMinusSrcAlpha)` blend; `ui_gamma.wgsl` decodes once. One pipeline, a flag per mode:
// BLEND (EGxBlend 2, `SrcAlpha/OneMinusSrcAlpha`): out = (rgb·a, a) ⇒ dst·(1−a) + rgb·a
// ADD (EGxBlend 3, `SrcAlpha/One`): out = (rgb·a, 0) ⇒ dst + rgb·a
// `(One, OneMinusSrcAlpha)` blend; `ui_gamma.wgsl` decodes once. One pipeline for four modes, a
// uniform per mode, and a second pipeline for MOD:
// DISABLE (EGxBlend 0, no blend): out = (rgb, 1) ⇒ rgb
// ALPHAKEY (EGxBlend 1, no blend, test a ≥ 224/255): out = (rgb, 1) ⇒ rgb, or discard
// BLEND (EGxBlend 2, `SrcAlpha/OneMinusSrcAlpha`): out = (rgb·a, a) ⇒ dst·(1−a) + rgb·a
// ADD (EGxBlend 3, `SrcAlpha/One`): out = (rgb·a, 0) ⇒ dst + rgb·a
// MOD (EGxBlend 4, `DestColor/Zero`, its own pipeline): out = rgb ⇒ dst·rgb
// Modes 2 to 4 test the alpha at 1/255 (`0x85ad20`), which only MOD can see.
// `linear_to_srgb` restores the authored byte of every sampled texel except a SKIP_DECODE upload
// (`gamma_texel`), which already holds it.
// Deviation: bilinear filtering runs on the decoded texel, not on the gamma byte the reference
@@ -57,7 +62,8 @@ fn vertex(vertex: Vertex) -> VertexOutput {
return out;
}
@group(2) @binding(0) var<uniform> additive: u32;
// The EGxBlend, 0 to 4 (`UiQuad::blend`).
@group(2) @binding(0) var<uniform> blend_mode: u32;
@group(2) @binding(1) var quad_texture: texture_2d<f32>;
@group(2) @binding(2) var quad_sampler: sampler;
// Mask to the inscribed circle: the unit portrait's round stencil, which the reference stamps into
@@ -93,6 +99,11 @@ fn vertex(vertex: Vertex) -> VertexOutput {
// Do not normalise: they sum just above 1.0 and white relies on the output clamp.
const LUMA: vec3<f32> = vec3<f32>(0.299, 0.587, 0.114);
// The alpha references `0x85ad20` gives ALPHAKEY (224) and MOD (1), half a byte below so a texel
// stored at the byte passes `GEQUAL`.
const ALPHAKEY_REF: f32 = 223.5 / 255.0;
const MOD_REF: f32 = 0.5 / 255.0;
// Linear to sRGB (IEC 61966-2-1), the exact inverse of the sampler's decode in f32.
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
@@ -126,19 +137,21 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
if desaturate != 0u {
rgb = vec3<f32>(dot(texel, LUMA));
}
// `k`, the UI's coverage (frame alpha and masks), stays apart from `t.a`: it scales a
// premultiplied source's colour and alpha alike, while `t.a` weights only a straight source.
var k = c.a;
// `m`, the masks' coverage, and `k`, the UI's (frame alpha and masks), stay apart from `t.a`:
// `k` scales a premultiplied source's colour and alpha alike, while `t.a` weights only a
// straight source.
var m = 1.0;
if circular != 0u {
// A soft edge 2% of the width, like the reference's stencil at portrait size.
k *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2<f32>(0.5)));
m *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2<f32>(0.5)));
}
if mask_rect.z > mask_rect.x {
let muv = (in.position.xy - mask_rect.xy) / (mask_rect.zw - mask_rect.xy);
let inside = f32(all(muv >= vec2<f32>(0.0)) && all(muv <= vec2<f32>(1.0)));
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(1.0)), 0.0).a;
k *= m * inside;
let cover = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(1.0)), 0.0).a;
m *= cover * inside;
}
let k = c.a * m;
// Alpha test: returns the linear texel, not `rgb`, because it draws only into the minimap's
// linear 256² composite, whose blit encodes.
if alpha_ref > 0.0 {
@@ -147,11 +160,34 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
}
return vec4<f32>(srgb_to_linear(t.rgb) * c.rgb, 1.0);
}
switch blend_mode {
// DISABLE and ALPHAKEY write the texel over the target; the masks still cut it.
case 0u: {
return vec4<f32>(rgb * m, m);
}
// An ALPHAKEY quad that reaches here has no colour array (the extract promotes one that
// does), so the diffuse it tests against is white: the texel alone.
case 1u: {
if t.a < ALPHAKEY_REF {
discard;
}
return vec4<f32>(rgb * m, m);
}
// MOD multiplies the target by the texel; a masked-out fragment multiplies by 1.
case 4u: {
// The test reads texel times vertex colour, the combine preset's alpha.
if t.a * c.a < MOD_REF {
discard;
}
return vec4<f32>(mix(vec3<f32>(1.0), rgb, m), 1.0);
}
default: {}
}
let a = t.a * k;
// 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 additive != 0u {
if blend_mode == 3u {
return vec4<f32>(rgb * weight, 0.0);
}
return vec4<f32>(rgb * weight, a);
+155 -26
View File
@@ -14,6 +14,7 @@
//! and material state, one `Mesh2d` draw each. A run never spans a quad of another state, so the
//! total order holds and interleaved textures pay one draw per switch.
use benilla_ui::script::BlendMode;
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::RenderLayers;
use bevy::camera::CameraOutputMode;
@@ -62,8 +63,8 @@ impl UvRect {
}
}
/// One resolved screen-space quad, the pass's whole input. `texture`, the flags from `additive`
/// to `uv_clamp`, and `mask` are material state: a quad differing in any of them starts a new run.
/// One resolved screen-space quad, the pass's whole input. `texture`, the fields from `blend` to
/// `uv_clamp`, and `mask` are material state: a quad differing in any of them starts a new run.
#[derive(Clone, PartialEq)]
pub(crate) struct UiQuad {
/// Screen pixels, y-down, anchor-resolved.
@@ -79,8 +80,11 @@ pub(crate) struct UiQuad {
/// corner and interpolated across the quad: a texture's `<Gradient>`, whose four colours the
/// reference submits with a vertex stride of 4 (`0x77304d`).
pub corner_colors: Option<[[f32; 4]; 4]>,
/// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`) instead of straight alpha.
pub additive: bool,
/// The EGxBlend the quad draws with, `SetBlendMode`'s five (`systems/gx/renderstate.md`):
/// `DISABLE` and `ALPHAKEY` blend nothing, the second with the alpha test at 224/255
/// (`0x85ad20[1]`); `BLEND` is `SrcAlpha/InvSrcAlpha`, `ADD` `SrcAlpha/One` and `MOD`
/// `DestColor/Zero` (`0x80a1fc`, `0x80a228`). A producer passes the mode the submit draws with.
pub blend: BlendMode,
/// 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
@@ -134,7 +138,7 @@ impl Default for UiQuad {
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, 1.0],
corner_colors: None,
additive: false,
blend: BlendMode::Blend,
circular: false,
desaturated: false,
premultiplied: false,
@@ -198,13 +202,15 @@ pub(crate) struct UiQuadBatch;
#[derive(Resource)]
struct UiWhiteTexture(Handle<Image>);
/// The quad material: a texture and the [`UiQuad`] flags as uniforms, drawn through one
/// premultiplied-alpha pipeline (`ui_quad.wgsl`). It holds no colour, so it is its identity key
/// alone and a steady frame re-prepares none.
/// The quad material: a texture and the [`UiQuad`] flags as uniforms, drawn through the
/// premultiplied-alpha pipeline (`ui_quad.wgsl`), or the multiply one for `MOD` ([`UiQuadKey`]). 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 {
/// The EGxBlend, 0 to 4 ([`UiQuad::blend`]).
#[uniform(0)]
additive: u32,
blend: u32,
#[texture(1)]
#[sampler(2)]
texture: Option<Handle<Image>>,
@@ -230,6 +236,21 @@ pub(crate) struct UiQuadMaterial {
uv_clamp: Vec4,
}
/// The pipeline axis of [`UiQuadMaterial`]: `MOD` alone needs a blend state of its own, since the
/// shader folds the other four modes into the one premultiplied state.
#[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.blend == BlendMode::Mod as u32,
}
}
}
/// 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`
@@ -251,7 +272,8 @@ impl UiQuadMaterial {
/// the forced blend of [`Self::specialize`] is a plain replace: the reference's blending off.
pub(crate) fn interior_tile(texture: Handle<Image>, alpha_ref: f32) -> Self {
Self {
additive: 0,
// The alpha-test arm returns before the blend modes.
blend: BlendMode::Blend as u32,
texture: Some(texture),
circular: 0,
desaturate: 0,
@@ -291,21 +313,32 @@ 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()) {
// `(One, OneMinusSrcAlpha)` over gamma values: the shader emits `(rgb·a, a)` for
// BLEND and `(rgb·a, 0)` for ADD, so this one state is EGxBlend 2 and EGxBlend 3,
// clamped at each write like the reference's 8-bit backbuffer.
let premultiplied = BlendComponent {
src_factor: BlendFactor::One,
dst_factor: BlendFactor::OneMinusSrcAlpha,
let component = |src_factor, dst_factor| BlendComponent {
src_factor,
dst_factor,
operation: BlendOperation::Add,
};
target.blend = Some(BlendState {
color: premultiplied,
alpha: premultiplied,
target.blend = Some(if key.bind_group_data.modulate {
// EGxBlend 4, `DestColor/Zero`: the shader emits the tinted texel, so the
// target is multiplied by it; its alpha stays.
BlendState {
color: component(BlendFactor::Dst, BlendFactor::Zero),
alpha: component(BlendFactor::Zero, BlendFactor::One),
}
} else {
// `(One, OneMinusSrcAlpha)` over gamma values: the shader emits `(rgb·a, a)`
// for BLEND, `(rgb·a, 0)` for ADD and `(rgb, 1)` for DISABLE and a passing
// ALPHAKEY texel, so this one state is EGxBlend 0 to 3, clamped at each write
// like the reference's 8-bit backbuffer.
let premultiplied = component(BlendFactor::One, BlendFactor::OneMinusSrcAlpha);
BlendState {
color: premultiplied,
alpha: premultiplied,
}
});
}
}
@@ -486,7 +519,7 @@ fn clip_quad(
/// One run: contiguous quads, in `z_key` order, sharing a texture and every material flag.
struct Run {
texture: Handle<Image>,
additive: bool,
blend: BlendMode,
circular: bool,
desaturated: bool,
premultiplied: bool,
@@ -505,7 +538,7 @@ impl Run {
fn new(texture: Handle<Image>, q: &UiQuad) -> Self {
Self {
texture,
additive: q.additive,
blend: q.blend,
circular: q.circular,
desaturated: q.desaturated,
premultiplied: q.premultiplied,
@@ -690,7 +723,7 @@ impl StoredRun {
/// A material's full identity, floats by their bits so byte-equal materials share one entry.
type MatKey = (
AssetId<Image>,
bool,
BlendMode,
bool,
bool,
bool,
@@ -919,7 +952,7 @@ fn rebuild_ui_mesh(
let texture = q.texture.clone().unwrap_or_else(|| white.0.clone());
let same_run = runs.last().is_some_and(|r| {
(r.texture == texture || one_texture_probe())
&& r.additive == q.additive
&& r.blend == q.blend
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.premultiplied == q.premultiplied
@@ -992,7 +1025,7 @@ fn rebuild_ui_mesh(
let uv_clamp = run.uv_clamp.map_or(UV_CLAMP_OFF, Vec4::from_array);
let key: MatKey = (
run.texture.id(),
run.additive,
run.blend,
run.circular,
run.desaturated,
run.premultiplied,
@@ -1032,7 +1065,7 @@ fn rebuild_ui_mesh(
.entry(key)
.or_insert_with(|| {
materials.add(UiQuadMaterial {
additive: u32::from(run.additive),
blend: run.blend as u32,
texture: Some(run.texture.clone()),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
@@ -1431,6 +1464,102 @@ mod tests {
);
}
/// Each texture mode reaches the pass as the reference sets it: `DISABLE`, `ALPHAKEY`,
/// `BLEND` and `ADD` share the premultiplied pipeline and differ by the mode uniform the shader
/// branches on, and `MOD` keys its own `DestColor/Zero` pipeline (`0x80a1fc[4] = 9`,
/// `0x80a228[4] = 1`).
#[test]
fn each_blend_mode_splits_the_run_and_mod_keys_the_multiply_pipeline() {
use bevy::render::render_resource::{
ColorTargetState, ColorWrites, FragmentState, RenderPipelineDescriptor,
};
let mut app = rebuild_app();
let mut quads = app.world_mut().resource_mut::<UiQuads>();
for (i, blend) in [
BlendMode::Disable,
BlendMode::AlphaKey,
BlendMode::Add,
BlendMode::Mod,
]
.into_iter()
.enumerate()
{
let x = 64.0 * (i as f32 + 1.0);
quads.quads.push(UiQuad {
rect: Rect::new(x, 0.0, x + 64.0, 64.0),
z_key: i as u128 + 1,
blend,
..default()
});
}
quads.dirty = true;
app.update();
assert_eq!(batches(&mut app), 5, "a run per mode");
let mut modes: Vec<(u32, bool)> = app
.world_mut()
.resource::<Assets<UiQuadMaterial>>()
.iter()
.map(|(_, m)| (m.blend, UiQuadKey::from(m).modulate))
.collect();
modes.sort_unstable();
assert_eq!(
modes,
vec![(0, false), (1, false), (2, false), (3, false), (4, true)],
"the EGxBlend as the uniform, and MOD alone on the multiply key"
);
let blend_of = |modulate: bool| {
let mut descriptor = RenderPipelineDescriptor {
fragment: Some(FragmentState {
targets: vec![Some(ColorTargetState {
format: TextureFormat::Rgba8Unorm,
blend: None,
write_mask: ColorWrites::ALL,
})],
..default()
}),
..default()
};
let layout = tile_quad_mesh()
.get_mesh_vertex_buffer_layout(&mut bevy::mesh::MeshVertexBufferLayouts::default());
UiQuadMaterial::specialize(
&mut descriptor,
&layout,
Material2dKey {
mesh_key: bevy::sprite_render::Mesh2dPipelineKey::empty(),
bind_group_data: UiQuadKey { modulate },
},
)
.unwrap();
descriptor.fragment.unwrap().targets[0]
.clone()
.unwrap()
.blend
.unwrap()
};
let component = |src_factor, dst_factor| BlendComponent {
src_factor,
dst_factor,
operation: BlendOperation::Add,
};
assert_eq!(
blend_of(true),
BlendState {
color: component(BlendFactor::Dst, BlendFactor::Zero),
alpha: component(BlendFactor::Zero, BlendFactor::One),
},
"MOD multiplies the target"
);
let premultiplied = component(BlendFactor::One, BlendFactor::OneMinusSrcAlpha);
assert_eq!(
blend_of(false),
BlendState {
color: premultiplied,
alpha: premultiplied,
}
);
}
/// `SetVertexColor` quantises `×255 + 0.5` into one byte per channel (`0x79abd0`).
#[test]
fn tint_tag_is_the_reference_byte_colour_complemented() {
@@ -1317,7 +1317,7 @@ fn convert_entry(
path,
color,
gradient,
additive,
blend,
tex_coords,
wrap,
circular,
@@ -1448,7 +1448,7 @@ fn convert_entry(
uv_clamp,
color,
corner_colors,
additive,
blend,
clip,
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
rotation: -rotation,
@@ -1667,7 +1667,7 @@ mod cursor_quad_tests {
assert_eq!(q.rect.height(), 32.0);
assert_eq!(q.z_key, u128::MAX);
assert!(q.texture.is_some());
assert!(!q.additive);
assert_eq!(q.blend, benilla_ui::script::BlendMode::Blend);
}
}
@@ -160,7 +160,7 @@ pub(super) fn emit(
uv: UvRect::FULL,
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
corner_colors: None,
additive: false,
blend: benilla_ui::script::BlendMode::Blend,
circular: false,
desaturated: false,
premultiplied: false,
@@ -358,7 +358,7 @@ pub(super) fn emit(
base_color[3] * host.alpha,
],
corner_colors: None,
additive: false,
blend: benilla_ui::script::BlendMode::Blend,
circular: false,
desaturated: false,
premultiplied: false,
@@ -235,11 +235,15 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
QuadContent::Texture {
path: Some(p),
color,
additive,
blend,
..
} if p.contains("UI-QuestLogTitleHighlight") => {
q.rect.map(|r| (r, color.unwrap_or([1.0; 4]), *additive))
}
} if p.contains("UI-QuestLogTitleHighlight") => q.rect.map(|r| {
(
r,
color.unwrap_or([1.0; 4]),
*blend == benilla_ui::script::BlendMode::Add,
)
}),
_ => None,
})
.collect()
+3 -1
View File
@@ -401,7 +401,9 @@ impl Loader<'_> {
}
}
if let Some(mode) = node.attr("alphaMode") {
self.call_region(wrapper, "SetBlendMode", mode.to_string(), dbg);
if let Err(e) = crate::script::load_blend_mode(self.lua, wrapper, mode) {
self.report.fault(format!("{dbg}: region alphaMode: {e}"));
}
}
}
+41 -4
View File
@@ -2634,19 +2634,56 @@ mod layer_blend_tests {
let mut s = s;
s.set_screen_size(800.0, 600.0);
s.resolve();
let additive = s
let blend = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
additive,
blend,
..
} if p == "Interface\\Glow" => Some(*additive),
} if p == "Interface\\Glow" => Some(*blend),
_ => None,
})
.expect("the layer texture extracted");
assert!(additive, "alphaMode=ADD must ride into the quad blend flag");
assert_eq!(
blend,
crate::script::BlendMode::Add,
"alphaMode=ADD must ride into the quad blend"
);
}
/// `alphaMode=` (`0x770176`) skips an empty value and ignores an unknown one with no log
/// (`0x770185`, `0x770195`), where `SetBlendMode` raises; a known one is any case.
#[test]
fn an_unknown_alpha_mode_is_ignored_without_a_fault() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="ModeHost">
<Layers><Layer level="ARTWORK">
<Texture name="ModeBogus" file="Interface\Bogus" alphaMode="ADDITIVE"/>
<Texture name="ModeEmpty" file="Interface\Empty" alphaMode=""/>
<Texture name="ModeMod" file="Interface\Mod" alphaMode="mod"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
report.warnings.is_empty(),
"warnings: {:?}",
report.warnings
);
let modes: (String, String, String) = s
.eval(
"return ModeBogus:GetBlendMode(), ModeEmpty:GetBlendMode(), \
ModeMod:GetBlendMode()",
)
.unwrap();
assert_eq!(modes, ("BLEND".into(), "BLEND".into(), "MOD".into()));
}
}
+17 -18
View File
@@ -587,27 +587,26 @@ fn set_slot_texture(
// a check box with a checked texture repaints that one and never gets its disabled-checked
// texture from a path. benilla tests and reloads the slot the method names.
let existing = with_button(lua, this, |bs| slot.get(bs))?.is_some();
let resolvable = path.is_empty()
|| lua
.app_data_ref::<Model>()
.expect("model app_data")
.texture_probe
.as_ref()
.is_none_or(|probe| probe(&path));
if !existing && !resolvable {
return Ok(());
}
// A slot built for this call is judged before it is built, the probe asked once.
let verdict = if existing {
None
} else {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match crate::script::texture_name_verdict(&model, &path) {
crate::script::FileLoad::Failed => return Ok(()),
v => Some(v),
}
};
let id = ensure_slot(lua, this, slot)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("slot region id");
if resolvable {
// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
let set = if path.is_empty() {
crate::script::texel::TexSet::None
} else {
crate::script::texel::TexSet::File(path)
};
model.set_texel(rh, set);
// `""` clears the texture, which `QuestLogFrame.lua:165` relies on; a file that loads clears
// the desaturation, and one that fails keeps the image and the grey (`0x778f3a` → `0x770200`).
match verdict {
Some(v) => crate::script::apply_texture_name(&mut model, rh, &path, false, v),
None => {
crate::script::load_texture_name(&mut model, rh, &path, false);
}
}
let data = model.region_data.entry(rh).or_default();
// The highlight applies its blend on every path call, to the old region as to a new one
+5 -13
View File
@@ -10,9 +10,7 @@ use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::pointer::point_in_rect;
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::region::{install_into_slot, load_path_leg, region_wrapper, slot_arg, SlotArg};
use super::{event, Model};
use crate::layout::Rect;
use crate::order::DrawLayer;
@@ -152,8 +150,8 @@ pub(super) fn install_loaded(
install_slot_model(model, h, slot, Some(rh))
}
/// A marker's path leg (`0x78b7b0`): a marker that exists has its file swapped and nothing else;
/// with none, a texture is built, given the file and installed.
/// A marker's path leg (`0x78b7b0`, [`load_path_leg`]): a marker that exists has its file swapped
/// and nothing else; with none, a texture that loads is installed.
fn set_marker_path(lua: &Lua, this: &Table, slot: Slot, path: &str) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -166,14 +164,8 @@ fn set_marker_path(lua: &Lua, this: &Table, slot: Slot, path: &str) -> mlua::Res
KindState::ColorSelect(s) => slot.get(s),
_ => return Err(mlua::Error::runtime("not a ColorSelect")),
};
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = new_texture_region(&mut model, h, slot.layer())
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
set_texture_path(&mut model, rh, path);
install_slot_model(&mut model, h, slot, Some(rh))?;
}
if let Some(rh) = load_path_leg(&mut model, h, existing, slot.layer(), path)? {
install_slot_model(&mut model, h, slot, Some(rh))?;
}
Ok(())
}
+41 -3
View File
@@ -335,9 +335,13 @@ impl UiScript {
.gradient
.filter(|_| has_texture)
.map(|g| g.vertex_colors()),
// The renderer acts on ADD only: the other four `alphaMode` values
// answer `GetBlendMode` but draw as straight alpha.
additive: data.blend == crate::script::BlendMode::Add,
blend: drawn_blend(
data.blend,
owner_frame.map_or(1.0, |f| f.effective_alpha),
data.alpha.unwrap_or(1.0),
data.vertex_color,
data.gradient.map(|g| g.vertex_colors()),
),
tex_coords: data.tex_coords,
wrap,
circular: data.circular,
@@ -408,6 +412,40 @@ impl UiScript {
}
}
/// The blend a texture's record draws with (`0x76fb00`): a record with a colour array and a mode
/// below `BLEND` draws as `BLEND` (`0x76fb85`-`0x76fba2`). The array is there exactly when it holds
/// an entry (`0x773035`), and the frame-alpha fold (`0x77fac0`) leaves one exactly when the frame's
/// alpha byte is below `0xfe` (`0x77fb6a` builds a white one) or an entry is not opaque white
/// (`0x77faf5`-`0x77fb23`). The entries are the vertex colour, whose alpha byte the region's
/// `SetAlpha` writes (`0x79b580`), or a gradient's four; a solid fill is the texel (`0x770360`).
fn drawn_blend(
mode: crate::script::BlendMode,
frame_alpha: f32,
region_alpha: f32,
vertex: Option<[f32; 4]>,
gradient: Option<[[f32; 4]; 4]>,
) -> crate::script::BlendMode {
use crate::script::BlendMode;
if !matches!(mode, BlendMode::Disable | BlendMode::AlphaKey) {
return mode;
}
// A channel's byte as the colour setters pack it (`×255 + 0.5`, truncated, `0x40a2b0`).
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let opaque_white =
|c: [f32; 4]| c[..3].iter().all(|&v| byte(v) == 0xff) && byte(c[3] * region_alpha) >= 0xfe;
let all_white = match (gradient, vertex) {
(Some(g), _) => g.into_iter().all(opaque_white),
(None, Some(c)) => opaque_white(c),
(None, None) => opaque_white([1.0; 4]),
};
if byte(frame_alpha) < 0xfe || !all_white {
BlendMode::Blend
} else {
mode
}
}
/// The colour a Texture region draws with: texel × vertex colour per channel, alpha included (the
/// stage-0 combine preset at submit, `.data 0x85c250` index 1: `MODULATE(TEXTURE, DIFFUSE)`). A
/// desaturated region's pixel shader (`+0x128`) reads only the vertex alpha. `fill`, the region's
+5 -3
View File
@@ -302,8 +302,9 @@ pub use quest::{
};
pub use quest_log::{QuestObjectiveView, QuestRecordView, QuestSlotView, QUEST_LOG_SLOTS};
pub(crate) use region::{
apply_font_parts, apply_wrap_attrs, implicit_creation_anchor_lua, install_loaded_texture,
load_texture_file, FileLoad,
apply_font_parts, apply_texture_name, apply_wrap_attrs, implicit_creation_anchor_lua,
install_loaded_texture, load_blend_mode, load_texture_file, load_texture_name,
texture_name_verdict, FileLoad,
};
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
pub use session::SessionRequest;
@@ -393,7 +394,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; 13] = [
"SetGradient",
"SetGradientAlpha",
"GetTexture",
@@ -405,6 +406,7 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
"SetTexCoordModifiesRect",
"GetTexCoordModifiesRect",
"SetDesaturated",
"IsDesaturated",
"GetVertexColor",
];
+31 -12
View File
@@ -192,7 +192,7 @@ pub(super) fn slot_arg(
/// A fresh anchorless Texture region of `owner` on `layer`, as the client builds the texture a
/// path leg loads (`0x76fc40`) before it installs it as an object.
pub(super) fn new_texture_region(
fn new_texture_region(
model: &mut Model,
owner: crate::widget::FrameHandle,
layer: crate::order::DrawLayer,
@@ -205,16 +205,32 @@ pub(super) fn new_texture_region(
Some(rh)
}
/// `CSimpleTexture::SetTexture(path)` (`0x770200`) on a region: the file replaces a solid fill, and
/// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
pub(super) fn set_texture_path(model: &mut Model, rh: RegionHandle, path: &str) {
let set = if path.is_empty() {
super::texel::TexSet::None
} else {
super::texel::TexSet::File(path.to_string())
};
model.set_texel(rh, set);
model.note_art(rh);
/// A widget's texture path leg (`SetStatusBarTexture` `0x783140`, `SetThumbTexture` `0x7897c0`, a
/// ColorSelect marker `0x78b7b0`) over its current texture `existing`. That texture loads the path
/// ([`paint::load_texture_name`]) and keeps its image when the load fails (`0x783165`). With none,
/// a texture is built on `layer` (`0x76fc40`) and loaded, and one whose load fails is deleted with
/// nothing installed (`0x7831b4` → `0x7831d9`, `0x789834` → `0x789849`, `0x78b824` → `0x78b837`);
/// here the verdict comes first, so a failure builds nothing. `""` loads, as no image. Answers the
/// texture built, for the caller to install.
pub(super) fn load_path_leg(
model: &mut Model,
owner: crate::widget::FrameHandle,
existing: Option<RegionHandle>,
layer: crate::order::DrawLayer,
path: &str,
) -> mlua::Result<Option<RegionHandle>> {
if let Some(rh) = existing {
paint::load_texture_name(model, rh, path, false);
return Ok(None);
}
let verdict = paint::texture_name_verdict(model, path);
if verdict == paint::FileLoad::Failed {
return Ok(None);
}
let rh = new_texture_region(model, owner, layer)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
paint::apply_texture_name(model, rh, path, false, verdict);
Ok(Some(rh))
}
/// The loader's install of the texture a `<BarTexture>`, `<ThumbTexture>`, Button state-texture or
@@ -445,7 +461,10 @@ mod layout;
mod paint;
mod text;
pub(crate) use paint::{load_texture_file, orientation, FileLoad};
pub(crate) use paint::{
apply_texture_name, load_blend_mode, load_texture_file, load_texture_name, orientation,
texture_name_verdict, FileLoad,
};
pub(crate) use text::store_text_of;
fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
+186 -160
View File
@@ -112,14 +112,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
_ => None,
};
let Some(vertical) = vertical else {
let model = lua.app_data_ref::<Model>().expect("model");
let who = crate::script::object::decode_id(&this)
.ok()
.and_then(|id| super::region_name_of(&model, id));
return Err(mlua::Error::runtime(format!(
"Usage: {}:{name}{usage}",
who.as_deref().unwrap_or("<unnamed>")
)));
return Err(self::usage(lua, &this, &format!("{name}{usage}")));
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
@@ -164,10 +157,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// The path form reads one argument (`0x770200`), the colour form (`0x770360`) up to four,
// and extras such as `SetTexture(path, true)` are ignored: the wrap flag is 0 always
// (`0x79bda5`). It answers 1, or nil when the path form's file does not load, as the
// host's `Model::texture_probe` judges; with no probe the path form answers nil.
// host's `Model::texture_probe` judges ([`set_texture`]).
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
set_texture(lua, this, arg, g, b, a, false)
set_texture(lua, this, arg, g, b, a)
},
)?,
)?;
@@ -193,6 +186,20 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// IsDesaturated (`0x79c2c0`), Texture only: 1 when the shader slot (`+0x128`) holds the
// desaturate shader (`0x770620(1)`, `0x79c354`), else nil. `SetDesaturated` answers the shader
// supported, so this is the stored flag, which a file that loads clears ([`load_texture_name`]).
m.set(
"IsDesaturated",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(crate::script::binding_abi::flag(
model.region_data.get(&rh).is_some_and(|d| d.desaturated),
))
})?,
)?;
// 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.
@@ -229,17 +236,25 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// SetBlendMode (`0x79a950`, the alphaMode enum `0x811aa8`) stores the mode as given; an
// unknown name leaves it alone, as the enum lookup does. Only ADD draws differently here, the
// others as straight alpha.
// SetBlendMode (`0x79a950`) looks a `lua_isstring` argument (`0x79a9e0`, a number too) up in
// the alphaMode enum, ignoring case (`0x6f1920` over `0x811aa8`), and stores it
// (`0x79aa13`); a miss or any other value raises the usage (`0x79aa20`, `0x87c44c`) and
// stores nothing.
m.set(
"SetBlendMode",
lua.create_function(|lua, (this, mode): (Table, String)| {
lua.create_function(|lua, (this, mode): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let blend = match &mode {
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
.coerce_string(mode.clone())?
.and_then(|m| BlendMode::parse(&m.to_string_lossy())),
_ => None,
};
let Some(blend) = blend else {
return Err(usage(lua, &this, "SetBlendMode(\"mode\")"));
};
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(blend) = BlendMode::parse(&mode) {
model.region_data.entry(rh).or_default().blend = blend;
}
model.region_data.entry(rh).or_default().blend = blend;
Ok(())
})?,
)?;
@@ -305,16 +320,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
_ => None,
};
let Some(dl) = dl else {
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
crate::script::object::decode_id(&this)
.ok()
.and_then(|id| super::region_name_of(&model, id))
};
return Err(mlua::Error::runtime(format!(
"Usage: {}:SetDrawLayer(\"layer\")",
name.as_deref().unwrap_or("<unnamed>")
)));
return Err(usage(lua, &this, "SetDrawLayer(\"layer\")"));
};
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(region) = model.arena.region_mut(rh) {
@@ -347,34 +353,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
m.set(
"SetTexCoord",
// Each coordinate is a bare `lua_tonumber` (`0x79beb0`), so nil or a table is 0; only the
// count is gated, 4 or 8 (`0x79bf5d`).
// count is gated, `lua_gettop` less the receiver (`0x79bf58`), so a trailing nil counts.
// Any count but 4 or 8, none included, raises the usage (`0x79bf6c`, `0x87c538`).
lua.create_function(|lua, (this, args): (Table, mlua::Variadic<Value>)| {
let rest: Vec<f32> = args.iter().map(|v| tonumber_f32(lua, v)).collect();
let rh = region_handle_of(lua, &this)?;
// The eight come UL, LL, UR, LR; `TexCoords::Corners` stores TL, TR, BR, BL.
let coords = match rest.len() {
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
// The arguments come UL, LL, UR, LR; `TexCoords::Corners` stores TL, TR, BR, BL.
8 => Some(TexCoords::Corners([
4 => TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]]),
8 => 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 arguments resets to the full texture, which is not 1.12's: its gate takes 4
// or 8.
0 => None,
n => {
return Err(mlua::Error::runtime(format!(
"SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}"
)))
}
]),
_ => return Err(usage(lua, &this, TEX_COORD_USAGE)),
};
lua.app_data_mut::<Model>()
.expect("model")
.region_data
.entry(rh)
.or_default()
.tex_coords = coords;
.tex_coords = Some(coords);
Ok(())
})?,
)?;
@@ -416,6 +416,36 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(())
}
/// `SetTexCoord`'s usage line (`0x87c538`), after its `%s:`.
const TEX_COORD_USAGE: &str = "SetTexCoord(minX, maxX, minY, maxY) or SetTexCoord(ULx, ULy, LLx, \
LLy, URx, URy, LRx, LRy)";
/// The XML `alphaMode=` of a texture (`0x770176`): read with the same enum lookup as
/// `SetBlendMode` (`0x6f1920`), but an empty value skips and an unknown one is ignored, with no
/// log (`0x770185`, `0x770195`).
pub(crate) fn load_blend_mode(lua: &Lua, this: &Table, mode: &str) -> mlua::Result<()> {
let Some(blend) = BlendMode::parse(mode) else {
return Ok(());
};
let rh = region_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().blend = blend;
Ok(())
}
/// `Usage: <name>:<line>`, the raise a region method makes over its own format string, naming the
/// region by its `GetName` (vtable `+4`) or `<unnamed>` (`0x84c7f0`).
fn usage(lua: &Lua, this: &Table, line: &str) -> mlua::Error {
let model = lua.app_data_ref::<Model>().expect("model");
let name = crate::script::object::decode_id(this)
.ok()
.and_then(|id| super::region_name_of(&model, id));
mlua::Error::runtime(format!(
"Usage: {}:{line}",
name.as_deref().unwrap_or("<unnamed>")
))
}
impl crate::script::UiScript {
/// Install the host's texture-path check behind the path form of `SetTexture`'s 1-or-nil
/// answer; without one every path answers nil.
@@ -454,154 +484,150 @@ impl crate::script::UiScript {
}
}
/// What an XML `file=` load came to ([`load_texture_file`]).
/// What `CSimpleTexture::SetTexture(name)` (`0x770200`) came to ([`load_texture_name`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FileLoad {
/// The host's texture probe found the file.
/// It returned 1: the file it already shows, an empty name, or a file the probe found.
Loaded,
/// The probe did not: `0x770200` returned 0 and the texture kept what it had (`0x7702ac`).
/// It returned 0: the probe did not find the file, and the texture kept what it had.
Failed,
/// No probe is installed (a test harness): the path is stored, with no verdict.
Unprobed,
}
/// The XML `<Texture file=>` load of a region (`0x76fe20` → `0x770200`): the path form of
/// [`set_texture`] with the wrap flag the element's `<TexCoords>` decide.
/// `CSimpleTexture::SetTexture(name)` (`0x770200`) on region `rh`, the one load behind
/// `SetTexture`'s path form, the XML `file=` and every widget's path leg. The file the texture
/// already shows answers 1 untouched (`0x770225`); anything else is judged by
/// [`texture_name_verdict`] and applied by [`apply_texture_name`].
pub(crate) fn load_texture_name(
model: &mut Model,
rh: crate::widget::RegionHandle,
name: &str,
wrap: bool,
) -> FileLoad {
let held = model
.region_data
.get(&rh)
.is_some_and(|d| !name.is_empty() && model.texels.holds_file(&d.tex, name));
if held {
return FileLoad::Loaded;
}
let verdict = texture_name_verdict(model, name);
apply_texture_name(model, rh, name, wrap, verdict);
verdict
}
/// What `0x770200` comes to for a name the texture does not already show: an empty name, which is
/// how a null one arrives, loads as no image (`0x770235` → `0x7702cb`, answering 1); any other is
/// the host probe's to judge, and a miss answers 0 (`0x7702ac`). The probe is asked once here, so a
/// path leg that builds its texture only for a load that succeeds asks before it builds.
pub(crate) fn texture_name_verdict(model: &Model, name: &str) -> FileLoad {
if name.is_empty() {
return FileLoad::Loaded;
}
match &model.texture_probe {
None => FileLoad::Unprobed,
Some(probe) if probe(name) => FileLoad::Loaded,
Some(_) => FileLoad::Failed,
}
}
/// `0x770200` past its probe on region `rh`. A failed load keeps the image and the shader slot
/// (`0x7702ac`); an empty name drops the image and leaves the slot (`0x7702cb`); a file writes
/// shader 0 to the slot (`0x7702c0`), clearing any desaturation, and then the image. Every caller
/// passes shader index 0 (`0x79bd89`, `0x783165`, `0x7897e5`, `0x78b7d5`, `0x778f3a`).
pub(crate) fn apply_texture_name(
model: &mut Model,
rh: crate::widget::RegionHandle,
name: &str,
wrap: bool,
verdict: FileLoad,
) {
if verdict == FileLoad::Failed {
return;
}
let Model {
region_data,
texels,
..
} = &mut *model;
let data = region_data.entry(rh).or_default();
let set = if name.is_empty() {
TexSet::None
} else {
data.desaturated = false;
if wrap {
TexSet::WrapFile(name.to_string())
} else {
TexSet::File(name.to_string())
}
};
// The probe has answered already, so the file is no miss.
texels.set(&mut data.tex, set, |_| false);
model.note_art(rh);
}
/// The XML `<Texture file=>` load of a region (`0x76fe20` → `0x770200`), with the wrap flag the
/// element's `<TexCoords>` decide.
pub(crate) fn load_texture_file(
lua: &Lua,
this: &Table,
file: &str,
wrap: bool,
) -> mlua::Result<FileLoad> {
let probed = lua
.app_data_ref::<Model>()
.expect("model")
.texture_probe
.is_some();
let path = Value::String(lua.create_string(file)?);
let answer = set_texture(
lua,
this.clone(),
path,
Value::Nil,
Value::Nil,
Value::Nil,
wrap,
)?;
Ok(match (probed, answer) {
(false, _) => FileLoad::Unprobed,
(true, Value::Nil) => FileLoad::Failed,
(true, _) => FileLoad::Loaded,
})
let rh = region_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
Ok(load_texture_name(&mut model, rh, file, wrap))
}
/// `Texture:SetTexture` (`0x79bb40`) and the XML `<Texture file=>` load (`0x76fe20`), the two
/// callers of `0x770200` that differ in its wrap argument: the binding passes 0 (`0x79bda5`), the
/// loader passes 1 only when the element's `<TexCoords>` leave [0, 1] (`0x77012d`).
pub(crate) fn set_texture(
/// `Texture:SetTexture` (`0x79bb40`). `lua_isnumber` (`0x79bbd5`, so a numeric string too) picks
/// the colour form (`0x770360`); anything else is a name for `0x770200` with the wrap flag 0
/// (`0x79bda5`): a string as it is, and every other value, nil and no argument included, as a null
/// name (`0x79bdc8`), which clears. It answers 1 unless the load returns 0 (`0x79bdac` →
/// `0x79bddc`); with no probe installed the path form answers nil.
fn set_texture(
lua: &Lua,
this: Table,
arg: Value,
g: Value,
b: Value,
a: Value,
wrap: bool,
) -> mlua::Result<Value> {
let rh = region_handle_of(lua, &this)?;
// `lua_isnumber` (`0x79bbd5`) picks the colour form, a numeric string included.
let colour_form = lua.coerce_number(arg.clone()).ok().flatten().is_some();
// Scoped so the model borrow ends before the layout touch; the path arm drops it
// mid-way to call the host probe.
let loaded = {
let mut model = lua.app_data_mut::<Model>().expect("model");
{
let Model {
region_data,
texels,
..
} = &mut *model;
// SetTexture drops any portrait mask and live-unit binding.
let data = region_data.entry(rh).or_default();
data.circular = false;
data.portrait_unit = None;
// SetTexture clears the desaturation: `0x770200` writes the shader slot
// (`+0x128`) from an argument the binding (`0x79bb40`) always passes NULL. The
// same file returns first (`0x770225`) and keeps it, nil or "" clear it, and
// the colour form (`0x770360`) never writes it.
let same_file = matches!(&arg, Value::String(s)
if s.to_str().is_ok_and(|s| texels.holds_file(&data.tex, &s)));
if !same_file && !colour_form {
data.desaturated = false;
}
// SetTexture drops any portrait mask and live-unit binding.
let data = model.region_data.entry(rh).or_default();
data.circular = false;
data.portrait_unit = None;
if colour_form {
// g and b are a bare `lua_tonumber`, and alpha is `lua_isnumber`-gated with default 1.
// The solid writes the one texture slot (`+0xcc`), so it clears a file; neither form
// touches the vertex colour (`+0xb8`) or the shader slot, so a tint and a grey outlive
// the art.
let alpha = lua
.coerce_number(a.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32);
let fill = [
tonumber_f32(lua, &arg),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
alpha,
];
model.set_texel(rh, TexSet::Fill(fill));
model.note_art(rh);
true
} else {
let name = match &arg {
Value::String(s) => s.to_str()?.to_string(),
_ => String::new(),
};
load_texture_name(&mut model, rh, &name, false) == FileLoad::Loaded
}
// Both forms write the one texture slot (`+0xcc`), a file or an 8×8 solid, so
// each clears the other; neither touches the vertex colour (`+0xb8`), so a
// tint outlives its art.
let (set, loaded) = match &arg {
// The colour form (`lua_isnumber`, `0x79bbd5`, so a numeric string too): g
// and b are a bare `lua_tonumber`, and alpha is `lua_isnumber`-gated with
// default 1.
_ if colour_form => {
let alpha = lua
.coerce_number(a.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32);
let fill = [
tonumber_f32(lua, &arg),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
alpha,
];
(Some(TexSet::Fill(fill)), true)
}
// "" clears, as nil does (stock `QuestLogFrame.lua:166`), and answers nil
// like a failed load; the reference's answer for "" is untraced.
Value::String(s) if s.to_str()?.is_empty() => (Some(TexSet::None), false),
// Ask the probe before writing: on a failed load the reference returns 0
// and keeps the texture it had (`0x770288`, `0x77028e`-`0x7702b2`). With
// no probe the path is stored, though the answer is nil.
Value::String(s) => {
let path = s.to_str()?.to_string();
drop(model);
let resolvable = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.texture_probe
.as_ref()
.is_none_or(|probe| probe(&path))
};
model = lua.app_data_mut::<Model>().expect("model");
let had_probe = model.texture_probe.is_some();
if resolvable {
// Resolvable already: the probe need not run again.
let Model {
region_data,
texels,
..
} = &mut *model;
let data = region_data.entry(rh).or_default();
let set = if wrap {
TexSet::WrapFile(path)
} else {
TexSet::File(path)
};
texels.set(&mut data.tex, set, |_| false);
}
(None, resolvable && had_probe)
}
// nil clears, so the region draws nothing, and answers 1 (`0x79bb40`).
Value::Nil => (Some(TexSet::None), true),
_ => (None, false),
};
if let Some(set) = set {
model.set_texel(rh, set);
}
loaded
};
// The draw only (`0x77fdf0`); an art-sized texture now holds a state its next
// reaching resolve need not give back.
lua.app_data_mut::<Model>().expect("model").note_art(rh);
Ok(if loaded {
Value::Number(1.0)
} else {
+6 -14
View File
@@ -10,9 +10,7 @@ use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::pointer::point_in_rect;
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::region::{install_into_slot, load_path_leg, region_wrapper, slot_arg, SlotArg};
use super::{event, Model};
use crate::layout::Rect;
use crate::order::DrawLayer;
@@ -87,9 +85,9 @@ pub(super) fn install_loaded(
install_thumb_model(model, h, Some(rh), layer.unwrap_or(DrawLayer::Overlay))
}
/// `SetThumbTexture`'s path leg (`0x7897c0`): a thumb that exists has its file swapped and nothing
/// else; with none, a texture is built, given the file and installed on OVERLAY (`0x790431`'s
/// layer).
/// `SetThumbTexture`'s path leg (`0x7897c0`, [`load_path_leg`]): a thumb that exists has its file
/// swapped and nothing else; with none, a texture that loads is installed on OVERLAY
/// (`0x790431`'s layer).
fn set_thumb_path(lua: &Lua, this: &Table, path: &str) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -102,14 +100,8 @@ fn set_thumb_path(lua: &Lua, this: &Table, path: &str) -> mlua::Result<()> {
KindState::Slider(s) => s.thumb,
_ => return Err(mlua::Error::runtime("not a Slider")),
};
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = new_texture_region(&mut model, h, DrawLayer::Overlay)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
set_texture_path(&mut model, rh, path);
install_thumb_model(&mut model, h, Some(rh), DrawLayer::Overlay)?;
}
if let Some(rh) = load_path_leg(&mut model, h, existing, DrawLayer::Overlay, path)? {
install_thumb_model(&mut model, h, Some(rh), DrawLayer::Overlay)?;
}
Ok(())
}
+8 -16
View File
@@ -7,9 +7,7 @@
use mlua::{Lua, Table, Value};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::region::{install_into_slot, load_path_leg, region_wrapper, slot_arg, SlotArg};
use super::{event, Model};
use crate::order::DrawLayer;
use crate::widget::{KindState, RegionHandle, StatusBarState};
@@ -110,9 +108,9 @@ pub(super) fn install_loaded(
install_bar_model(model, h, Some(rh), layer.unwrap_or(DrawLayer::Artwork))
}
/// `SetStatusBarTexture`'s path leg (`0x783140`): a bar that exists has its file swapped and
/// nothing else, its layer included; with none, a texture is built, given the file and installed
/// on `layer` (`0x7831cb`).
/// `SetStatusBarTexture`'s path leg (`0x783140`, [`load_path_leg`]): a bar that exists has its file
/// swapped and nothing else, its layer included; with none, a texture that loads is installed on
/// `layer` (`0x7831cb`).
fn set_bar_path(lua: &Lua, this: &Table, path: &str, layer: DrawLayer) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -125,16 +123,10 @@ fn set_bar_path(lua: &Lua, this: &Table, path: &str, layer: DrawLayer) -> mlua::
KindState::StatusBar(sb) => sb.bar,
_ => return Err(mlua::Error::runtime("not a StatusBar")),
};
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = new_texture_region(&mut model, h, layer)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
set_texture_path(&mut model, rh, path);
// `SetAllPoints` onto the bar (`0x7831bf`).
super::region::implicit_creation_anchor(&mut model, rh);
install_bar_model(&mut model, h, Some(rh), layer)?;
}
if let Some(rh) = load_path_leg(&mut model, h, existing, layer, path)? {
// `SetAllPoints` onto the bar (`0x7831bf`).
super::region::implicit_creation_anchor(&mut model, rh);
install_bar_model(&mut model, h, Some(rh), layer)?;
}
Ok(())
}
@@ -506,6 +506,104 @@ fn a_path_that_does_not_load_makes_no_slot_region_and_keeps_the_old_texture() {
.unwrap();
}
/// The bar, thumb and marker path legs (`0x783140`, `0x7897c0`, `0x78b7b0`) load into a texture
/// they hold, which keeps its image when the load fails (`0x770200` → `0x7702ac`); with none, a
/// texture whose load fails is deleted and nothing is installed (`0x7831d9`, `0x789849`, `0x78b837`),
/// so a later good path builds one on the layer that call names.
#[test]
fn a_widget_path_that_does_not_load_installs_nothing_and_keeps_the_old_texture() {
let mut s = script();
s.set_texture_probe(Box::new(|p| p != "missing"));
s.run(
r#"
sb = CreateFrame("StatusBar")
sl = CreateFrame("Slider")
cs = CreateFrame("ColorSelect")
sb:SetStatusBarTexture("missing")
sl:SetThumbTexture("missing")
cs:SetColorWheelThumbTexture("missing")
assert(sb:GetStatusBarTexture() == nil, "no bar for a failed load")
assert(sl:GetThumbTexture() == nil, "no thumb")
assert(cs:GetColorWheelThumbTexture() == nil, "no marker")
sb:SetStatusBarTexture("present", "BORDER")
assert(sb:GetStatusBarTexture():GetDrawLayer() == "BORDER", "built on the good call's layer")
sl:SetThumbTexture("present")
cs:SetColorWheelThumbTexture("present")
for _, t in ipairs({ sb:GetStatusBarTexture(), sl:GetThumbTexture(),
cs:GetColorWheelThumbTexture() }) do
t:SetDesaturated(1)
end
sb:SetStatusBarTexture("missing")
sl:SetThumbTexture("missing")
cs:SetColorWheelThumbTexture("missing")
for _, t in ipairs({ sb:GetStatusBarTexture(), sl:GetThumbTexture(),
cs:GetColorWheelThumbTexture() }) do
assert(t:GetTexture() == "present", "the old texture stands")
assert(t:IsDesaturated() == 1, "and keeps its grey")
end
sb:SetStatusBarTexture("other")
sl:SetThumbTexture("other")
cs:SetColorWheelThumbTexture("other")
for _, t in ipairs({ sb:GetStatusBarTexture(), sl:GetThumbTexture(),
cs:GetColorWheelThumbTexture() }) do
assert(t:GetTexture() == "other" and t:IsDesaturated() == nil, "a new file clears it")
end
"#,
)
.unwrap();
}
/// A path leg that builds its texture asks the probe once, as `0x770200` loads once.
#[test]
fn a_path_leg_that_builds_a_texture_probes_once() {
let mut s = script();
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
let seen = calls.clone();
s.set_texture_probe(Box::new(move |_| {
seen.set(seen.get() + 1);
true
}));
for (make, call) in [
("StatusBar", "f:SetStatusBarTexture('present')"),
("Slider", "f:SetThumbTexture('present')"),
("ColorSelect", "f:SetColorWheelThumbTexture('present')"),
("Button", "f:SetNormalTexture('present')"),
(
"Button",
"f:SetNormalTexture('present') f:SetNormalTexture('other')",
),
] {
calls.set(0);
s.run(&format!("f = CreateFrame('{make}') {call}")).unwrap();
let loads = call.matches("Texture(").count();
assert_eq!(calls.get(), loads, "{call}: one probe per load");
}
}
/// A Button state texture's path leg loads through `0x770200` with shader index 0 (`0x778f3a`): a
/// new file that loads clears the desaturation, one that fails keeps it.
#[test]
fn a_button_path_that_loads_clears_the_desaturation() {
let mut s = script();
s.set_texture_probe(Box::new(|p| p != "missing"));
s.run(
r#"
b = CreateFrame("Button")
b:SetNormalTexture("present")
n = b:GetNormalTexture()
n:SetDesaturated(1)
b:SetNormalTexture("missing")
assert(n:GetTexture() == "present" and n:IsDesaturated() == 1, "a failed load keeps both")
b:SetNormalTexture("present")
assert(n:IsDesaturated() == 1, "the same file returns first")
b:SetNormalTexture("other")
assert(n:GetTexture() == "other" and n:IsDesaturated() == nil, "a new file clears it")
"#,
)
.unwrap();
}
/// The Button setters' raises (`0x87a210`/`0x87a1e0`, the highlight's `0x87a2cc`/`0x87a28c`, the
/// checked pair's `0x87b224`/`0x87b1e8` and `0x87c0e8`/`0x87c0a0`) name the receiver and the
/// method; a call with no argument raises Usage and clears nothing (`0x781b4f`).
+5 -2
View File
@@ -538,7 +538,10 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() {
// The 1.12 client's `SetHighlightTexture` defaults the texture to ADD.
assert!(matches!(
&find("Interface\\Hi.blp").content,
QuadContent::Texture { additive: true, .. }
QuadContent::Texture {
blend: BlendMode::Add,
..
}
));
// The reference's string setter anchors a texture it builds with `SetAllPoints`, so the later
// 64px size is unread and the ring fills the 36px button.
@@ -578,7 +581,7 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() {
assert!(matches!(
&hi.content,
QuadContent::Texture {
additive: false,
blend: BlendMode::Blend,
..
}
));
+168 -24
View File
@@ -544,8 +544,8 @@ fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
assert!(!grey(&s, true), "clearing the flag restores full colour");
}
/// A Texture region's desaturation state, read off the model by name: `IsDesaturated`
/// (`0x79c2c0`) is not built, and a cleared texture emits no quad to read it from.
/// A Texture region's desaturation state, read off the model by name: a cleared texture emits no
/// quad to read it from.
fn desaturated(s: &UiScript, name: &str) -> bool {
let lua = s.lua();
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
@@ -554,40 +554,96 @@ fn desaturated(s: &UiScript, name: &str) -> bool {
model.region_data.get(&h).is_some_and(|d| d.desaturated)
}
/// The desaturation is the shader slot `+0x128`, which `CSimpleTexture::SetTexture` rewrites with
/// the binding's always-NULL slot 0, except that the same path returns early (`0x770225`).
/// The desaturation is the shader slot `+0x128`, which `0x770200` writes only for a file that
/// loads (`0x7702c0`): the file the texture shows returns first (`0x770225`), a null or empty name
/// skips the write (`0x77022f`, `0x770238`), a failed load returns before it (`0x7702ac`), and the
/// colour form (`0x770360`) never writes it. `IsDesaturated` (`0x79c2c0`) reads it back.
#[test]
fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
let s = script();
fn only_a_file_that_loads_clears_the_desaturation() {
let mut s = script();
s.set_texture_probe(Box::new(|path: &str| !path.contains("Missing")));
s.run(
r#"
local f = CreateFrame("Frame", "ClrOwner")
icon = f:CreateTexture("ClrIcon", "ARTWORK")
icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
icon:SetDesaturated(1)
"#,
)
.unwrap();
let grey = |s: &UiScript| desaturated(s, "ClrIcon");
let grey = |s: &UiScript| {
let read = s.eval::<bool>("return icon:IsDesaturated() == 1").unwrap();
assert_eq!(
read,
desaturated(s, "ClrIcon"),
"IsDesaturated reads the flag"
);
read
};
assert!(
s.eval::<bool>("return icon:IsDesaturated() == nil")
.unwrap(),
"a fresh texture answers nil"
);
s.run("icon:SetDesaturated(1)").unwrap();
assert!(grey(&s));
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")"#)
.unwrap();
assert!(grey(&s), "re-setting the SAME art keeps the grey");
assert!(grey(&s), "the same art returns before the write");
assert!(s
.eval::<bool>(r#"return icon:SetTexture("Interface\\Missing") == nil"#)
.unwrap());
assert!(grey(&s), "a failed load returns before the write");
assert_eq!(
s.eval::<String>("return icon:GetTexture()").unwrap(),
"Interface\\Icons\\Spell_Nature_Sleep",
"and keeps the art"
);
for clear in [
"icon:SetTexture(nil)",
"icon:SetTexture('')",
"icon:SetTexture(true)",
] {
s.run(&format!(
"icon:SetTexture('Interface\\\\Art') icon:SetDesaturated(1) {clear}"
))
.unwrap();
assert!(grey(&s), "{clear}: a null name skips the write");
}
s.run("icon:SetTexture(1, 0, 0)").unwrap();
assert!(grey(&s), "the colour form does not touch the shader slot");
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Fire_Fireball")"#)
.unwrap();
assert!(!grey(&s), "a texture CHANGE clears the desaturation");
// nil takes the same leg (`test esi,esi` falls through to the write).
s.run("icon:SetDesaturated(1) icon:SetTexture(nil)")
assert!(!grey(&s), "a file that loads clears it");
s.run("icon:SetDesaturated(1) icon:SetDesaturated(nil)")
.unwrap();
assert!(!grey(&s), "SetTexture(nil) clears it too");
assert!(!grey(&s), "SetDesaturated(nil) clears it");
}
// The colour form (`0x770360`) does not write the shader slot.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep") icon:SetDesaturated(1)"#)
/// Every name but a string goes to `0x770200` as a null one (`0x79bdc8`), and so does `""`
/// (`0x770235`): the image is dropped and the binding answers 1 (`0x79bdae`).
#[test]
fn set_texture_with_an_empty_or_non_string_name_clears_and_answers_one() {
let mut s = script();
s.set_texture_probe(Box::new(|_: &str| true));
s.run(r#"f = CreateFrame("Frame") t = f:CreateTexture()"#)
.unwrap();
s.run("icon:SetTexture(1, 0, 0)").unwrap();
assert!(grey(&s), "the colour form does not touch the shader slot");
for arg in ["''", "true", "{}", "nil", ""] {
let answer = s
.eval::<Option<f64>>(&format!(
r#"t:SetTexture("Interface\\Art") return t:SetTexture({arg})"#
))
.unwrap();
assert_eq!(answer, Some(1.0), "SetTexture({arg}) answers 1");
assert!(
s.eval::<bool>("return t:GetTexture() == nil").unwrap(),
"SetTexture({arg}) drops the image"
);
}
}
/// `SetDesaturated` reads its flag with `0x6f1c10(L, 2, default=1)`: no argument (`LUA_TNONE`)
@@ -1261,6 +1317,83 @@ fn an_unresolvable_set_texture_keeps_the_art_the_region_had() {
assert!(s.eval::<bool>("return t:GetTexture() == nil").unwrap());
}
/// The blend each texture record draws with (`0x76fb00`): its own mode, except that `DISABLE` and
/// `ALPHAKEY` under a colour array draw as `BLEND` (`0x76fb93`). The array survives the frame-alpha
/// fold (`0x77fac0`) when the frame's alpha byte is below `0xfe` or an entry is not opaque white;
/// a solid fill is the texel and no entry (`0x770360`).
#[test]
fn disable_and_alphakey_draw_as_blend_under_a_vertex_colour() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
DrawHost = CreateFrame("Frame", "DrawHost")
DrawHost:SetPoint("CENTER", 0, 0)
DrawHost:SetWidth(10) DrawHost:SetHeight(10)
DrawTex = DrawHost:CreateTexture("DrawTex", "ARTWORK")
DrawTex:SetAllPoints(DrawHost)
"#,
)
.unwrap();
let drawn = |s: &mut UiScript, setup: &str| {
s.run(&format!(
"DrawHost:SetAlpha(1) DrawTex:SetAlpha(1) DrawTex:SetVertexColor(1, 1, 1, 1) \
DrawTex:SetTexture('Interface\\\\Art') {setup}"
))
.unwrap();
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Texture { blend, .. } => Some(blend),
_ => None,
})
.expect("the texture draws")
};
for (setup, want) in [
("DrawTex:SetBlendMode('DISABLE')", BlendMode::Disable),
("DrawTex:SetBlendMode('ALPHAKEY')", BlendMode::AlphaKey),
(
"DrawTex:SetBlendMode('DISABLE') DrawTex:SetVertexColor(1, 1, 1)",
BlendMode::Disable,
),
(
"DrawTex:SetBlendMode('DISABLE') DrawTex:SetTexture(1, 0, 0, 0.5)",
BlendMode::Disable,
),
(
"DrawTex:SetBlendMode('DISABLE') DrawTex:SetVertexColor(1, 0, 0)",
BlendMode::Blend,
),
(
"DrawTex:SetBlendMode('ALPHAKEY') DrawTex:SetVertexColor(1, 1, 1, 0.5)",
BlendMode::Blend,
),
(
"DrawTex:SetBlendMode('DISABLE') DrawTex:SetAlpha(0.5)",
BlendMode::Blend,
),
(
"DrawTex:SetBlendMode('ALPHAKEY') DrawHost:SetAlpha(0.5)",
BlendMode::Blend,
),
(
"DrawTex:SetBlendMode('DISABLE') DrawTex:SetGradient('VERTICAL', 1, 1, 1, 0, 0, 0)",
BlendMode::Blend,
),
(
"DrawTex:SetBlendMode('MOD') DrawTex:SetVertexColor(1, 0, 0)",
BlendMode::Mod,
),
(
"DrawTex:SetBlendMode('ADD') DrawHost:SetAlpha(0.5)",
BlendMode::Add,
),
] {
assert_eq!(drawn(&mut s, setup), want, "{setup}");
}
}
/// `Texture:GetBlendMode` (`0x79a890`, table `0x87c128`) answers one string, the mode
/// `SetBlendMode` (`0x79a950`) set, `"BLEND"` by default (the ctor's `[+0xd0] = 2`, `0x76fc64`).
#[test]
@@ -1301,12 +1434,23 @@ fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() {
s.eval::<String>("return BTex:GetBlendMode()").unwrap(),
"ADD"
);
s.run(r#"BTex:SetBlendMode("NOT_A_MODE")"#).unwrap();
assert_eq!(
s.eval::<String>("return BTex:GetBlendMode()").unwrap(),
"ADD",
"an unknown name changes nothing"
);
// A name off the enum, or a value that is no string or number, raises the usage
// (`0x79aa20`, `0x87c44c`) and stores nothing.
for bad in [r#""ADDITIVE""#, "nil", "true", "3", ""] {
let err = s
.run(&format!("BTex:SetBlendMode({bad})"))
.expect_err(bad)
.to_string();
assert!(
err.contains(r#"Usage: BTex:SetBlendMode("mode")"#),
"{bad}: {err}"
);
assert_eq!(
s.eval::<String>("return BTex:GetBlendMode()").unwrap(),
"ADD",
"{bad} changes nothing"
);
}
assert!(s
.eval::<bool>(
@@ -51,21 +51,30 @@ fn set_tex_coord_changes_extracted_uv() {
"the affine form carries corners in screen winding, got {:?}",
tex.content
);
// A no-arg reset returns to the full texture.
s.run("t:SetTexCoord()").unwrap();
s.resolve();
let tex = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Foo")))
.unwrap();
assert!(matches!(
&tex.content,
QuadContent::Texture {
tex_coords: None,
..
}
));
// Every count but 4 and 8 raises the usage (`0x79bf6c`, `0x87c538`) naming the texture, and
// the coordinates stay; the count is `lua_gettop`, so a trailing nil counts.
let usage = "Usage: TcTex:SetTexCoord(minX, maxX, minY, maxY) or SetTexCoord(ULx, ULy, LLx, \
LLy, URx, URy, LRx, LRy)";
for call in [
"t:SetTexCoord()",
"t:SetTexCoord(0, 1, 0, 1, nil)",
"t:SetTexCoord(0, 1)",
] {
let err = s.run(call).expect_err(call).to_string();
assert!(err.contains(usage), "{call}: {err}");
}
let unnamed = s
.run("f:CreateTexture():SetTexCoord()")
.expect_err("an unnamed texture raises too")
.to_string();
assert!(
unnamed.contains("Usage: <unnamed>:SetTexCoord("),
"{unnamed}"
);
let still: (f32, f32, f32, f32, f32, f32, f32, f32) = s.eval("return t:GetTexCoord()").unwrap();
assert_eq!(still, (0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0), "unchanged");
s.run("t:SetTexCoord(0, 1, 0, 1, nil, nil, nil, nil)")
.expect("eight with trailing nils is the corner form");
}
#[test]
@@ -102,12 +102,6 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
"the RGB pair is published; nothing measured asks for HSV",
),
("SetColorHSV", "ColorSelect", "the same"),
// ── One texture read-back ──
(
"IsDesaturated",
"Texture",
"`SetDesaturated` is here and not readable back; no caller measured",
),
];
/// Methods answered here that 1.12 does not register on that class, each with why it stays.
+16 -15
View File
@@ -110,8 +110,9 @@ pub enum QuadContent {
/// A gradient's four vertex colours, `[TL, TR, BR, BL]` ([`Gradient::vertex_colors`]),
/// each multiplying the texel and `color`; `None` for a one-colour quad.
gradient: Option<[[f32; 4]; 4]>,
/// The `ADD` blend instead of straight alpha.
additive: bool,
/// The blend the record draws with: the region's `SetBlendMode` mode, except that
/// `DISABLE` and `ALPHAKEY` draw as `BLEND` under a vertex colour (`0x76fb93`).
blend: BlendMode,
/// `None` is the full texture.
tex_coords: Option<TexCoords>,
/// The wrap flag the file loaded with (`0x770200`, bits 3 and 4 of the texture's flags):
@@ -201,21 +202,21 @@ pub enum JustifyV {
}
/// A Texture's blend mode, the client's `alphaMode` enum (`0x811aa8`), set by XML `alphaMode=`
/// or `SetBlendMode`; BLEND is the constructor's (`0x76fc64 mov [esi+0xd0],2`). The renderer
/// acts only on ADD: the others draw as straight alpha, their reference blending not built.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// or `SetBlendMode`; BLEND is the constructor's (`0x76fc64 mov [esi+0xd0],2`). The discriminant
/// is the EGxBlend the submit sets (`0x76fba2`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum BlendMode {
/// `DISABLE` (0): `GL_ONE, GL_ZERO`, alpha reference 0.
Disable,
/// `ALPHAKEY` (1): `GL_ONE, GL_ZERO`, alpha reference 224/255.
AlphaKey,
/// `BLEND` (2): `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA`.
/// `DISABLE` (0): blending off, alpha reference 0 (no test).
Disable = 0,
/// `ALPHAKEY` (1): blending off, alpha reference 224/255.
AlphaKey = 1,
/// `BLEND` (2): `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA`, alpha reference 1/255.
#[default]
Blend,
/// `ADD` (3): `GL_SRC_ALPHA, GL_ONE`.
Add,
/// `MOD` (4): `GL_DST_COLOR, GL_ZERO`.
Mod,
Blend = 2,
/// `ADD` (3): `GL_SRC_ALPHA, GL_ONE`, alpha reference 1/255.
Add = 3,
/// `MOD` (4): `GL_DST_COLOR, GL_ZERO`, alpha reference 1/255.
Mod = 4,
}
impl BlendMode {
+5 -5
View File
@@ -232,7 +232,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### shaders
- Bilinear filtering runs on the decoded texel, not on the gamma byte the reference filters, because the booth bake stores linear bytes. ([`src/shaders/ui_quad.wgsl:8`](../crates/benilla-app/src/shaders/ui_quad.wgsl#L8))
- Bilinear filtering runs on the decoded texel, not on the gamma byte the reference filters, because the booth bake stores linear bytes. ([`src/shaders/ui_quad.wgsl:13`](../crates/benilla-app/src/shaders/ui_quad.wgsl#L13))
#### shutdown
@@ -657,7 +657,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The reference (`0x4abdc0`) admits 9 and reads one dword past its block; we answer 0 because that read is out of bounds. ([`src/script/battlefield_score.rs:292`](../crates/benilla-ui/src/script/battlefield_score.rs#L292))
- `pow(10, n)` is the platform's, where the reference's (`0x73f940`, `fyl2x`) rounds its extended-precision steps at 53 bits, so a power or a fraction term can differ in the last place. ([`src/script/binding_abi.rs:186`](../crates/benilla-ui/src/script/binding_abi.rs#L186))
- The disabled-checked leg (`0x785380`) tests and reloads the checked slot (`[+0x4e0]`, `0x785388`/`0x7853a5`), a copy of the checked leg's (`0x785270`), so in 1.12.1 a check box with a checked texture repaints that one and never gets its disabled-checked texture from a path. benilla tests and reloads the slot the method names. ([`src/script/button.rs:585`](../crates/benilla-ui/src/script/button.rs#L585))
- The slot is read before the handler, where the reference reads it after (`[esi+0x4dc]`), because reading first keeps the borrow simple and no stock handler re-slots its row. ([`src/script/button.rs:1171`](../crates/benilla-ui/src/script/button.rs#L1171))
- The slot is read before the handler, where the reference reads it after (`[esi+0x4dc]`), because reading first keeps the borrow simple and no stock handler re-slots its row. ([`src/script/button.rs:1170`](../crates/benilla-ui/src/script/button.rs#L1170))
- The reference composes the Shortcut with `GetRealZoneText` alone (`0x49efe8`), so in a capital `/leave Trade` sends `Trade - Stormwind City` and leaves nothing; ours uses the join's city-aware name, because the reference's form cannot leave the channel it joined. ([`src/script/channel.rs:480`](../crates/benilla-ui/src/script/channel.rs#L480))
- The frame's bottom-left origin is snapped onto the device grid so the border art blits 1:1; the reference ceils the anchor point itself onto it (`0x7666a0`, `0x766750`). ([`src/script/chat_bubble.rs:476`](../crates/benilla-ui/src/script/chat_bubble.rs#L476))
- No truncation at 255 characters (`0x49f604`), because vmangos refuses a longer line itself (`Chat.cpp:2177`): it is dropped where the reference sends its first 255. ([`src/script/chat_send.rs:23`](../crates/benilla-ui/src/script/chat_send.rs#L23))
@@ -665,7 +665,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:220`](../crates/benilla-ui/src/script/chat_window.rs#L220))
- One past `i32::MAX` clamps where the reference keeps the low 32 bits, for the colours' reason. ([`src/script/chat_window.rs:430`](../crates/benilla-ui/src/script/chat_window.rs#L430))
- The reference reads the clock live at each call (`0x42c010`); ours is the session clock the tick advances by the paced frame step, one value per frame, so `GetTime()` deltas and the `elapsed` handed to OnUpdate agree, as FrameXML (CastingBarFrame and kin) assumes. ([`src/script/clock.rs:53`](../crates/benilla-ui/src/script/clock.rs#L53))
- With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:364`](../crates/benilla-ui/src/script/colorselect.rs#L364))
- With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:356`](../crates/benilla-ui/src/script/colorselect.rs#L356))
- The reference's `set` registers an unknown name as a new category-5 record (`0x63d569`-`0x63d582`); benilla refuses it, because nothing would read the row and `config.toml` keeps an unknown key anyway. ([`src/script/console_exec.rs:120`](../crates/benilla-ui/src/script/console_exec.rs#L120))
- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:666`](../crates/benilla-ui/src/script/container.rs#L666))
- A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:104`](../crates/benilla-ui/src/script/craft.rs#L104))
@@ -697,7 +697,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- Nil for a display row with no icon, where the reference answers the unloadable `Interface\Icons\`, because `SetTexture` shows no icon for either. ([`src/script/item_stats.rs:482`](../crates/benilla-ui/src/script/item_stats.rs#L482))
- An addon file whose first binding has no header opens a `HEADER_<addon>` row, where the reference files those rows under the previous file's last header, because under a foreign header the player could not find them. ([`src/script/keybind.rs:292`](../crates/benilla-ui/src/script/keybind.rs#L292))
- The handle's value is the address of a `CGxTex` (`SMemAlloc(0x54)`, `0x594a96`), which benilla cannot reproduce; each texture ranks instead by a creation stamp, one global sequence number taken when its cache key is first created and taken anew once its last region has let go of it (`super::texel::Texels`). ([`src/script/layer_batch.rs:9`](../crates/benilla-ui/src/script/layer_batch.rs#L9))
- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:790`](../crates/benilla-ui/src/script/mod.rs#L790))
- The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it. ([`src/script/mod.rs:792`](../crates/benilla-ui/src/script/mod.rs#L792))
- A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:341`](../crates/benilla-ui/src/script/modelframe.rs#L341))
- Benilla plays no pre-rendered movie (#968), so `StartMovie` answers nil, the reference's "could not start" (`0x78d861`), which the stock `MovieFrame_PlayMovie` answers by hiding the frame and so by returning to the login screen. ([`src/script/movieframe.rs:4`](../crates/benilla-ui/src/script/movieframe.rs#L4))
- The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:429`](../crates/benilla-ui/src/script/nameplate.rs#L429))
@@ -735,7 +735,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:197`](../crates/benilla-ui/src/script/trainer/mod.rs#L197))
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:871`](../crates/benilla-ui/src/script/trainer/mod.rs#L871))
- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:893`](../crates/benilla-ui/src/script/trainer/mod.rs#L893))
- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because the drawn product differs only on a region that sets both, or a gradient and an alpha, which no stock code does, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:381`](../crates/benilla-ui/src/script/types.rs#L381))
- Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because the drawn product differs only on a region that sets both, or a gradient and an alpha, which no stock code does, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:382`](../crates/benilla-ui/src/script/types.rs#L382))
- UnitCanCooperate answers 1 for a friendly (5 and up) player, because the engine carries no faction templates; the reference resolves their cooperation masks. ([`src/script/unit/bindings.rs:612`](../crates/benilla-ui/src/script/unit/bindings.rs#L612))
- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1115`](../crates/benilla-ui/src/script/unit/bindings.rs#L1115))
- The reference ends the walk at the first whole-string hit (`0x493ca2`), so its pick follows the object list's order; we rank exact, then longer prefix, then nearest (`Rank::beats`), because the nearest namesake is the better pick, and a full tie goes to the lower guid (`Rank::beats_at`), so the VM's walk and the app's scan pick the same unit whatever order each walks in. ([`src/script/unit/by_name.rs:7`](../crates/benilla-ui/src/script/unit/by_name.rs#L7))