diff --git a/Cargo.lock b/Cargo.lock index a49560215..41320f454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,6 +522,7 @@ dependencies = [ "kira", "libc", "mint", + "naga", "num-bigint", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index d41f3122e..b3989cc8d 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -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"] } diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index 91e6215b9..b0e323ea5 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -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, mut quads: ResMut) { 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, diff --git a/crates/benilla-app/src/shaders.rs b/crates/benilla-app/src/shaders.rs index e9fe21cc8..63d96d3e1 100644 --- a/crates/benilla-app/src/shaders.rs +++ b/crates/benilla-app/src/shaders.rs @@ -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 = 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::(); + 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 = 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::::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 = 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:?}") + }); + } + } } diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index 15055ddea..a16a1462d 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -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 additive: u32; +// The EGxBlend, 0 to 4 (`UiQuad::blend`). +@group(2) @binding(0) var blend_mode: u32; @group(2) @binding(1) var quad_texture: texture_2d; @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 = vec3(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) -> vec3 { let higher = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; @@ -126,19 +137,21 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { if desaturate != 0u { rgb = vec3(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(0.5))); + m *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2(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(0.0)) && all(muv <= vec2(1.0))); - let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2(0.0), vec2(1.0)), 0.0).a; - k *= m * inside; + let cover = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2(0.0), vec2(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 { } return vec4(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(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(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(mix(vec3(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(rgb * weight, 0.0); } return vec4(rgb * weight, a); diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 8dc0bf94b..b72029c30 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -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 ``, 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); -/// 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>, @@ -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, 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, + key: Material2dKey, ) -> 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, - additive: bool, + blend: BlendMode, circular: bool, desaturated: bool, premultiplied: bool, @@ -505,7 +538,7 @@ impl Run { fn new(texture: Handle, 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, - 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::(); + 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::>() + .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() { diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 841fc1f3b..f8ba6f726 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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); } } diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index f848fd22a..46b944ae7 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 6fc122f00..2957ac570 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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() diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index e1a121064..d0ec3654b 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -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}")); + } } } diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 12ea4e1e2..b09ead00d 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -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#" + + + + + + + + "#, + ) + .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())); } } diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index cd59bf874..febdc226c 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -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::() - .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::().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::().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 diff --git a/crates/benilla-ui/src/script/colorselect.rs b/crates/benilla-ui/src/script/colorselect.rs index 0e65e84f0..77f163425 100644 --- a/crates/benilla-ui/src/script/colorselect.rs +++ b/crates/benilla-ui/src/script/colorselect.rs @@ -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::().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(()) } diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 3a80b97bc..70ddadb5d 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 557598120..f28d37d92 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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", ]; diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 682e6a3d9..b36359391 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -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, + layer: crate::order::DrawLayer, + path: &str, +) -> mlua::Result> { + 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 ``, ``, 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<()> { diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index eb35b3fd1..252bb4fb0 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -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::().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("") - ))); + 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::().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::().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::().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("") - ))); + return Err(usage(lua, &this, "SetDrawLayer(\"layer\")")); }; let mut model = lua.app_data_mut::().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)| { let rest: Vec = 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::() .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::().expect("model"); + model.region_data.entry(rh).or_default().blend = blend; + Ok(()) +} + +/// `Usage: :`, the raise a region method makes over its own format string, naming the +/// region by its `GetName` (vtable `+4`) or `` (`0x84c7f0`). +fn usage(lua: &Lua, this: &Table, line: &str) -> mlua::Error { + let model = lua.app_data_ref::().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("") + )) +} + 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 `` load of a region (`0x76fe20` → `0x770200`): the path form of -/// [`set_texture`] with the wrap flag the element's `` 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 `` load of a region (`0x76fe20` → `0x770200`), with the wrap flag the +/// element's `` decide. pub(crate) fn load_texture_file( lua: &Lua, this: &Table, file: &str, wrap: bool, ) -> mlua::Result { - let probed = lua - .app_data_ref::() - .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::().expect("model"); + Ok(load_texture_name(&mut model, rh, file, wrap)) } -/// `Texture:SetTexture` (`0x79bb40`) and the XML `` 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 `` 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 { 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::().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::().expect("model"); - model - .texture_probe - .as_ref() - .is_none_or(|probe| probe(&path)) - }; - model = lua.app_data_mut::().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::().expect("model").note_art(rh); Ok(if loaded { Value::Number(1.0) } else { diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index f457f3384..862231328 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -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::().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(()) } diff --git a/crates/benilla-ui/src/script/statusbar.rs b/crates/benilla-ui/src/script/statusbar.rs index bd033aa60..6586ff49f 100644 --- a/crates/benilla-ui/src/script/statusbar.rs +++ b/crates/benilla-ui/src/script/statusbar.rs @@ -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::().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(()) } diff --git a/crates/benilla-ui/src/script/tests/adopt.rs b/crates/benilla-ui/src/script/tests/adopt.rs index fdadf41cf..76492984d 100644 --- a/crates/benilla-ui/src/script/tests/adopt.rs +++ b/crates/benilla-ui/src/script/tests/adopt.rs @@ -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`). diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index 35c48c91a..af340736f 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -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, .. } )); diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index cf96381ea..39df408ca 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -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::().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::("return icon:IsDesaturated() == 1").unwrap(); + assert_eq!( + read, + desaturated(s, "ClrIcon"), + "IsDesaturated reads the flag" + ); + read + }; + assert!( + s.eval::("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::(r#"return icon:SetTexture("Interface\\Missing") == nil"#) + .unwrap()); + assert!(grey(&s), "a failed load returns before the write"); + assert_eq!( + s.eval::("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::>(&format!( + r#"t:SetTexture("Interface\\Art") return t:SetTexture({arg})"# + )) + .unwrap(); + assert_eq!(answer, Some(1.0), "SetTexture({arg}) answers 1"); + assert!( + s.eval::("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::("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::("return BTex:GetBlendMode()").unwrap(), "ADD" ); - s.run(r#"BTex:SetBlendMode("NOT_A_MODE")"#).unwrap(); - assert_eq!( - s.eval::("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::("return BTex:GetBlendMode()").unwrap(), + "ADD", + "{bad} changes nothing" + ); + } assert!(s .eval::( diff --git a/crates/benilla-ui/src/script/tests/texcoord_font.rs b/crates/benilla-ui/src/script/tests/texcoord_font.rs index fa00dc471..016ad0870 100644 --- a/crates/benilla-ui/src/script/tests/texcoord_font.rs +++ b/crates/benilla-ui/src/script/tests/texcoord_font.rs @@ -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: :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] diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 6f533ae2e..5ad05b357 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -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. diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 315121bc5..75f6c9bb3 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -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, /// 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 { diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index cbf89384f..b464cabec 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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_` 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))