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:
Generated
+1
@@ -522,6 +522,7 @@ dependencies = [
|
||||
"kira",
|
||||
"libc",
|
||||
"mint",
|
||||
"naga",
|
||||
"num-bigint",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user