Переглянути джерело

added regression tests, fixed multiple todos

UwU 4 днів тому
батько
коміт
4f476ed004
29 змінених файлів з 1359 додано та 161 видалено
  1. 9 2
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  2. 2 0
      src/WallpaperEngine/Data/Model/Model.h
  3. 1 0
      src/WallpaperEngine/Data/Parsers/ModelParser.cpp
  4. 1 0
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  5. 25 1
      src/WallpaperEngine/Render/CFBO.cpp
  6. 2 0
      src/WallpaperEngine/Render/CWallpaper.cpp
  7. 3 0
      src/WallpaperEngine/Render/CWallpaper.h
  8. 31 4
      src/WallpaperEngine/Render/FBOProvider.cpp
  9. 18 7
      src/WallpaperEngine/Render/Objects/CImage.cpp
  10. 1 0
      src/WallpaperEngine/Render/Objects/CImage.h
  11. 9 3
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  12. 11 4
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  13. 36 26
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  14. 39 9
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  15. 4 0
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  16. 2 2
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  17. 26 2
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  18. 99 12
      src/WallpaperEngine/Scripting/EngineObject.cpp
  19. 2 1
      src/WallpaperEngine/Scripting/InputObject.cpp
  20. 10 10
      src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
  21. 4 4
      src/WallpaperEngine/Scripting/Modules/MathModule.cpp
  22. 158 45
      src/WallpaperEngine/Scripting/SceneObject.cpp
  23. 96 22
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  24. 1 1
      src/WallpaperEngine/Scripting/ScriptEngine.h
  25. 27 6
      src/WallpaperEngine/Scripting/resources/builtins.js
  26. BIN
      tools/regression/__pycache__/regress.cpython-313.pyc
  27. 55 0
      tools/regression/fixed_clock.c
  28. 681 0
      tools/regression/regress.py
  29. 6 0
      tools/regression/variants.json

+ 9 - 2
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -2177,15 +2177,22 @@ void WallpaperApplication::render () {
 	timeinfo = localtime (&seconds);
 	g_Daytime = static_cast<float> ((timeinfo->tm_hour * 60) + timeinfo->tm_min) / (24.0f * 60.0f);
 
+	// LWE_FIXED_TIMESTEP=<seconds> advances animations by the same step every frame no matter how
+	// long the frame really took, so frame N always shows the same moment (regression renders)
+	static const float fixedTimestep = [] {
+	    const char* value = std::getenv ("LWE_FIXED_TIMESTEP");
+	    return value != nullptr ? std::max (0.0f, std::strtof (value, nullptr)) : 0.0f;
+	}();
+
 	const float rawTimeNow = m_videoDriver->getRenderTime ();
-	const float rawDelta = rawTimeNow - rawTimeLast;
+	const float rawDelta = fixedTimestep > 0.0f ? fixedTimestep : rawTimeNow - rawTimeLast;
 	rawTimeLast = rawTimeNow;
 
 	g_TimeLast = g_Time;
 	if (!this->m_context.settings.render.freezeAnimations) {
 	    g_Time += rawDelta * this->m_context.settings.render.playbackSpeed;
 	}
-	g_RealTime = rawTimeNow;
+	g_RealTime = fixedTimestep > 0.0f ? g_RealTime + fixedTimestep : rawTimeNow;
 	m_audioDriver->update ();
 	m_mediaSource->update ();
 	m_videoDriver->getInputContext ().update ();

+ 2 - 0
src/WallpaperEngine/Data/Model/Model.h

@@ -19,6 +19,8 @@ struct ModelStruct {
     bool fullscreen;
     bool passthrough;
     bool autosize;
+    /** Composition layer covering the whole project, autosize makes it the scene's size */
+    bool projectlayer;
     bool nopadding;
     /** Not sure what's used for */
     std::optional<int> width;

+ 1 - 0
src/WallpaperEngine/Data/Parsers/ModelParser.cpp

@@ -27,6 +27,7 @@ ModelUniquePtr ModelParser::parse (const JSON& file, const Project& project, con
 	.fullscreen = file.optional ("fullscreen", false),
 	.passthrough = file.optional ("passthrough", false),
 	.autosize = file.optional ("autosize", false),
+	.projectlayer = file.optional ("projectlayer", false),
 	.nopadding = file.optional ("nopadding", false),
 	.width = file.optional<int> ("width"),
 	.height = file.optional<int> ("height"),

+ 1 - 0
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -501,6 +501,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		    .fullscreen = false,
 		    .passthrough = false,
 		    .autosize = false,
+		    .projectlayer = false,
 		    .nopadding = false,
 		    .width = std::nullopt,
 		    .height = std::nullopt,

+ 25 - 1
src/WallpaperEngine/Render/CFBO.cpp

@@ -3,6 +3,22 @@
 
 using namespace WallpaperEngine::Render;
 
+namespace {
+// 3-channel formats get an alpha channel, RGB targets aren't guaranteed to be renderable
+GLint internalFormat (const TextureFormat format) {
+    switch (format) {
+	case TextureFormat_RG88: return GL_RG8;
+	case TextureFormat_R8: return GL_R8;
+	case TextureFormat_RG1616f: return GL_RG16F;
+	case TextureFormat_R16f: return GL_R16F;
+	case TextureFormat_RGBA16161616f:
+	case TextureFormat_RGB161616f: return GL_RGBA16F;
+	case TextureFormat_RGBa1010102: return GL_RGB10_A2;
+	default: return GL_RGBA8;
+    }
+}
+} // namespace
+
 CFBO::CFBO (
     std::string name, const TextureFormat format, const uint32_t flags, const float scale, uint32_t realWidth,
     uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight, const glm::vec4& borderColor
@@ -12,7 +28,9 @@ CFBO::CFBO (
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_framebuffer);
     glGenTextures (1, &this->m_texture);
     glBindTexture (GL_TEXTURE_2D, this->m_texture);
-    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    glTexImage2D (
+	GL_TEXTURE_2D, 0, internalFormat (format), textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr
+    );
 #if !NDEBUG
     glObjectLabel (GL_TEXTURE, this->m_texture, -1, this->m_name.c_str ());
 #endif /* DEBUG */
@@ -43,6 +61,12 @@ CFBO::CFBO (
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_texture, 0);
     glDrawBuffers (1, drawBuffers);
 
+    if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE && internalFormat (format) != GL_RGBA8) {
+	sLog.error ("FBO ", this->m_name, " can't render to format ", format, ", falling back to RGBA8");
+	glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+	this->m_format = TextureFormat_ARGB8888;
+    }
+
     if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
 	sLog.exception ("Framebuffers are not properly set");
     }

+ 2 - 0
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -186,6 +186,8 @@ void CWallpaper::render (
 	? glm::ivec4 { 0, 0, this->m_spanInfo->totalBounds.z, this->m_spanInfo->totalBounds.w }
 	: viewport;
 
+    this->m_screenSize = { sceneViewport.z, sceneViewport.w };
+
 #if !NDEBUG
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene");
 #endif /* !NDEBUG */

+ 3 - 0
src/WallpaperEngine/Render/CWallpaper.h

@@ -95,6 +95,8 @@ public:
     /** Size of the framebuffer the wallpaper renders into, only a scene can make this bigger than getWidth/getHeight */
     [[nodiscard]] virtual int getCanvasWidth () const { return this->getWidth (); }
     [[nodiscard]] virtual int getCanvasHeight () const { return this->getHeight (); }
+    /** Size of the output the wallpaper was last rendered to, zero until the first frame */
+    [[nodiscard]] const glm::ivec2& getScreenSize () const { return this->m_screenSize; }
 
     // maxRenderSize is only meaningful for Web wallpapers (see CWeb) - the largest resolution this
     // wallpaper instance will ever be asked to render at: a single screen's size normally, or a span
@@ -145,5 +147,6 @@ private:
     std::optional<SpanInfo> m_spanInfo = std::nullopt;
     // Avoids redundant renderFrame calls when the same wallpaper is shared across viewports (span mode)
     uint32_t m_lastRenderedFrame = UINT32_MAX;
+    glm::ivec2 m_screenSize = { 0, 0 };
 };
 } // namespace WallpaperEngine::Render

+ 31 - 4
src/WallpaperEngine/Render/FBOProvider.cpp

@@ -1,15 +1,42 @@
 #include "FBOProvider.h"
 
+#include <algorithm>
+#include <cctype>
+#include <map>
+
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Data::Model;
 
+namespace {
+// same names and fallback as wallpaper64.exe's lookup table (sub_140172ED0). the backbuffer formats
+// only switch to half floats in HDR scenes, which aren't supported here, so they stay 8-bit
+TextureFormat parseFormat (std::string name) {
+    static const std::map<std::string, TextureFormat> formats = {
+	{ "rgba8888", TextureFormat_ARGB8888 },
+	{ "rgba_backbuffer", TextureFormat_ARGB8888 },
+	{ "rgb888", TextureFormat_RGB888 },
+	{ "rgb_backbuffer", TextureFormat_RGB888 },
+	{ "rgb565", TextureFormat_RGB565 },
+	{ "rg88", TextureFormat_RG88 },
+	{ "r8", TextureFormat_R8 },
+	{ "rg1616f", TextureFormat_RG1616f },
+	{ "r16f", TextureFormat_R16f },
+	{ "rgba16161616f", TextureFormat_RGBA16161616f },
+	{ "rgb161616f", TextureFormat_RGB161616f },
+    };
+
+    std::ranges::transform (name, name.begin (), [] (const unsigned char c) { return std::tolower (c); });
+
+    const auto it = formats.find (name);
+    return it != formats.end () ? it->second : TextureFormat_ARGB8888;
+}
+} // namespace
+
 FBOProvider::FBOProvider (const FBOProvider* parent) : m_parent (parent) { }
 
 std::shared_ptr<CFBO> FBOProvider::create (const FBO& base, uint32_t flags, const glm::vec2 size) {
     return this->m_fbos[base.name] = std::make_shared<CFBO> (
-	       base.name,
-	       // TODO: properly determine FBO format based on the string
-	       TextureFormat_ARGB8888, flags, base.scale, size.x / base.scale, size.y / base.scale, size.x / base.scale,
+	       base.name, parseFormat (base.format), flags, base.scale, size.x / base.scale, size.y / base.scale, size.x / base.scale,
 	       size.y / base.scale
 	   );
 }
@@ -19,7 +46,7 @@ std::shared_ptr<CFBO> FBOProvider::create (
     glm::vec2 textureSize, const glm::vec4& borderColor
 ) {
     return this->m_fbos[name] = std::make_shared<CFBO> (
-	       name, TextureFormat_ARGB8888, flags, scale, realSize.x, realSize.y, textureSize.x, textureSize.y,
+	       name, format, flags, scale, realSize.x, realSize.y, textureSize.x, textureSize.y,
 	       borderColor
 	   );
 }

+ 18 - 7
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -888,8 +888,17 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	size.y = static_cast<float> (this->getImage ().model->height.value ());
     }
 
+    // autosize takes the size of the loaded texture (a single frame for sprite sheets) over the
+    // declared one, or the scene's for project layers. fullscreen still wins over it
+    if (this->getImage ().model->autosize && this->getImage ().model->projectlayer) {
+	size = { scene_width, scene_height };
+    } else if (this->getImage ().model->autosize && !placeholderTexture
+	&& std::dynamic_pointer_cast<const CFBO> (this->m_texture) == nullptr) {
+	size.x = static_cast<float> (this->m_texture->getRealWidth ());
+	size.y = static_cast<float> (this->m_texture->getRealHeight ());
+    }
+
     // fullscreen layers should use the whole projection's size
-    // TODO: WHAT SHOULD AUTOSIZE DO?
     if (this->getImage ().model->fullscreen) {
 	size = { static_cast<float> (scene.getCanvasWidth ()), static_cast<float> (scene.getCanvasHeight ()) };
 	origin = { scene_width / 2, scene_height / 2, 0 };
@@ -953,7 +962,6 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	    / static_cast<float> (this->getTexture ()->getTextureHeight (0));
     }
 
-    // TODO: RECALCULATE THESE POSITIONS FOR PASSTHROUGH SO THEY TAKE THE RIGHT PART OF THE TEXTURE
     float x = 0.0f;
     float y = 0.0f;
 
@@ -1873,8 +1881,8 @@ void CImage::setup () {
 	return;
     }
 
-    // TODO: SUPPORT PASSTHROUGH (IT'S A SHADER)
-    // passthrough without effects has nothing to draw
+    // passthrough without effects has nothing to draw, WE doesn't composite these either
+    // (sub_140175830 only takes the offscreen path with effects or a non-normal blend mode)
     if (this->m_image.model->passthrough && this->m_image.effects.empty ()) {
 	return;
     }
@@ -2094,8 +2102,9 @@ void CImage::setupPasses () {
 	return !pass->getTarget ().has_value ();
     });
 
-    if (!this->m_passes.empty () && !this->m_passes.back ()->getTarget ().has_value ()
-	&& this->shouldRenderFinalPass (true)) {
+    this->m_passesDrawToScreen = this->shouldRenderFinalPass (true);
+
+    if (!this->m_passes.empty () && !this->m_passes.back ()->getTarget ().has_value () && this->m_passesDrawToScreen) {
 	offscreenPasses--;
     }
 
@@ -2251,7 +2260,9 @@ void CImage::render () {
 	return;
     }
 
-    if (this->effectVisibilityChanged ()) {
+    // the last pass only goes to the screen if the layer was visible when the passes were set up,
+    // layers a script shows later (hidden in scene.json) need that redone
+    if (this->effectVisibilityChanged () || this->shouldRenderFinalPass (true) != this->m_passesDrawToScreen) {
 	this->rebuildActivePasses ();
     }
 

+ 1 - 0
src/WallpaperEngine/Render/Objects/CImage.h

@@ -247,6 +247,7 @@ private:
     std::vector<PassState> m_allPassStates = {};
     std::vector<bool> m_activePassMask = {};
     bool m_hasActiveEffectPass = false;
+    bool m_passesDrawToScreen = false;
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
     glm::vec4 m_pos = {};

+ 9 - 3
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -8,6 +8,7 @@
 #include <GL/glew.h>
 #include <algorithm>
 #include <cmath>
+#include <cstdlib>
 #include <glm/gtc/constants.hpp>
 #include <glm/gtc/matrix_transform.hpp>
 
@@ -27,8 +28,12 @@ CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
     this->registerProperty ("parallaxDepth", *particle.parallaxDepth->value);
 
     this->detectTexture ();
-    std::random_device rd;
-    m_rng.seed (rd ());
+    if (std::getenv ("LWE_FIXED_TIMESTEP") != nullptr) {
+	m_rng.seed (static_cast<std::mt19937::result_type> (this->getId ()));
+    } else {
+	std::random_device rd;
+	m_rng.seed (rd ());
+    }
 
     // Read renderer config early - buffer sizing below depends on it
     if (!m_particle.renderers.empty ()) {
@@ -311,8 +316,9 @@ void CParticle::update (float dt) {
 
 	    if (m_particle.animationMode == "randomframe") {
 		if (p.frame < 0.0f) {
+		    // per slot rather than per address, the address changes between runs
 		    std::mt19937 particleRng (
-			static_cast<std::mt19937::result_type> (reinterpret_cast<uintptr_t> (&p))
+			static_cast<std::mt19937::result_type> (i + this->getId () * 2654435761u)
 		    );
 		    std::uniform_int_distribution<int> dist (0, m_spritesheetFrames - 1);
 		    p.frame = static_cast<float> (dist (particleRng));

+ 11 - 4
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -396,9 +396,8 @@ CPass::resolveTextureAnimationState (const std::shared_ptr<const TextureProvider
 	return state;
     }
 
-    double currentRenderTime = fmod (
-	static_cast<double> (this->getContext ().getDriver ().getRenderTime ()), this->m_renderable.getAnimationTime ()
-    );
+    // scene time like every other animation, so --speed, --disable-animations and pausing apply
+    double currentRenderTime = fmod (static_cast<double> (g_Time), this->m_renderable.getAnimationTime ());
 
     for (const auto& frameCur : texture->getFrames ()) {
 	currentRenderTime -= frameCur->frametime;
@@ -806,9 +805,17 @@ void CPass::setupShaders () {
 	}
     }
 
+    // same for the object's own user textures, otherwise the combo that enables their slot stays off
+    TextureMap overrideTextures = this->m_override.textures;
+    for (const auto& [index, propertyName] : this->m_override.usertextures) {
+	if (const auto resolved = this->resolveUserTextureName (propertyName); resolved.has_value ()) {
+	    overrideTextures.insert_or_assign (index, *resolved);
+	}
+    }
+
     this->m_shader = new Render::Shaders::Shader (
 	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos, passTextures,
-	this->m_override.textures, this->m_override.constants
+	overrideTextures, this->m_override.constants
     );
 
     auto [vertex, fragment] = Shaders::GLSLContext::get ().toGlsl (this->m_shader->vertex (), this->m_shader->fragment ());

+ 36 - 26
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -3,6 +3,7 @@
 #include "WallpaperEngine/Logging/Log.h"
 #include <cctype>
 #include <charconv>
+#include <cmath>
 #include <exception>
 #include <mutex>
 #include <optional>
@@ -670,13 +671,14 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
 	const std::string name = (*it)[2].str ();
 
 	// only shadow varyings the shader actually reassigns - a plain read-only "in" is fine as-is,
-	// and touching the declaration unnecessarily risks breaking a shader that works today
-	// a typed local of the same name already shadows the varying, blank the write or the shadow local collides
+	// and touching the declaration unnecessarily risks breaking a shader that works today.
+	// writes to a typed local of the same name don't count, that local already shadows the varying
 	const std::regex localDecl ("\\b(?:vec[234]|float|int|bool)\\s+" + name + "\\b");
 	const std::string withoutLocals = std::regex_replace (source, localDecl, " ");
 
 	const std::regex assignmentUse (
-	    "\\b" + name + "\\b(?:\\.[xyzwrgba]+)?\\s*(?:=(?!=)|\\+=|-=|\\*=|/=)"
+	    "(?:\\b" + name + "\\b(?:\\.[xyzwrgba]+)?\\s*(?:=(?!=)|\\+=|-=|\\*=|/=|\\+\\+|--))|(?:(?:\\+\\+|--)\\s*"
+	    + name + "\\b)"
 	);
 	if (!std::regex_search (withoutLocals, assignmentUse)) {
 	    continue;
@@ -690,20 +692,27 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
     }
 
     static const std::regex mainOpen (R"(\bvoid\s+main\s*\([^)]*\)\s*\{)");
-    std::smatch mainMatch;
-    if (!std::regex_search (source, mainMatch, mainOpen)) {
+    if (!std::regex_search (source, mainOpen)) {
 	return source;
     }
 
-    // shadows each with a same-named local, initialized from the real (read-only) input, so the
-    // rest of main() can keep mutating it exactly like the original compatibility-profile shader did
-    std::string shadowCode;
+    // every use goes through a writable global copy instead, filled from the real (read-only)
+    // input at the top of main(). a global rather than a local in main() so helper functions
+    // writing to it work too, and the input keeps its name so it still links to the vertex output
+    std::string copyCode;
     for (const auto& [type, name] : shadowed) {
-	shadowCode += " " + type + " wpeShadowIn_" + name + " = " + name + "; " + type + " " + name + " = wpeShadowIn_" + name + ";";
+	const std::string copy = "wpeVar_" + name;
+	const std::regex use ("(^|[^.\\w])" + name + "\\b");
+	const std::regex decl ("\\bvarying\\s+" + type + "\\s+" + copy + "\\s*;");
+
+	source = std::regex_replace (source, use, "$1" + copy);
+	source = std::regex_replace (source, decl, "varying " + type + " " + name + "; " + type + " " + copy + ";");
+	copyCode += " " + copy + " = " + name + ";";
     }
 
-    const size_t insertAt = mainMatch.position (0) + mainMatch.length (0);
-    source.insert (insertAt, shadowCode);
+    std::smatch mainMatch;
+    std::regex_search (source, mainMatch, mainOpen);
+    source.insert (mainMatch.position (0) + mainMatch.length (0), copyCode);
 
     std::string names;
     for (const auto& [type, name] : shadowed) {
@@ -717,7 +726,7 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
 std::string ShaderUnit::applyNonConstantConstCompatibility (std::string source) const {
     // locals only, globals sit at column 0
     static const std::regex constLocal (R"((^|\n)([ \t]+)const\s+([^;=]+=([^;]*);))");
-    static const std::regex nonConstant (R"(\b(?:texSample2D\w*|texture\w*|g_\w+|v_\w+)\b)");
+    static const std::regex nonConstant (R"(\b(?:texSample2D\w*|texture\w*|g_\w+|v_\w+|wpeVar_\w+)\b)");
 
     std::string result;
     size_t count = 0;
@@ -748,7 +757,8 @@ std::string ShaderUnit::applyNonConstantConstCompatibility (std::string source)
 }
 
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
-    // TODO: SUPPORT REQUIRES SO WE PROPERLY FOLLOW THE REQUIRED CHAIN
+    // "require"/"requireany" on a combo are editor-only, they decide whether the editor shows the
+    // option. wallpaper64.exe (sub_140133B60) never reads them, it just takes the default
     JSON data;
     try {
 	data = JSON::parseAsset (content);
@@ -767,18 +777,20 @@ void ShaderUnit::parseComboConfiguration (const std::string& content, const int
 
     // not predefined anywhere -> fall back to the JSON's own default value
     if (entry == this->m_combos.end () && entryOverride == this->m_overrideCombos.end ()) {
-	if (defvalue == data.end ()) {
-	    // TODO: PROPERLY SUPPORT EMPTY COMBOS
-	    this->m_discoveredCombos.emplace (combo, defaultValue);
-	} else if (defvalue->is_number_float ()) {
-	    sLog.exception ("float combos are not supported in shader ", this->m_file, ". ", combo);
-	} else if (defvalue->is_number_integer ()) {
-	    this->m_discoveredCombos.emplace (combo, defvalue->get<int> ());
-	} else if (defvalue->is_string ()) {
-	    sLog.exception ("string combos are not supported in shader ", this->m_file, ". ", combo);
-	} else {
-	    sLog.exception ("cannot parse combo information ", combo, ". unknown type for ", defvalue->dump ());
+	// like WE: whole numbers (floats included) are taken as-is, a missing or any other default is 0
+	int value = defaultValue;
+
+	if (defvalue != data.end () && defvalue->is_number_integer ()) {
+	    value = defvalue->get<int> ();
+	} else if (defvalue != data.end () && defvalue->is_number_float ()) {
+	    const double number = defvalue->get<double> ();
+
+	    if (std::trunc (number) == number) {
+		value = static_cast<int> (number);
+	    }
 	}
+
+	this->m_discoveredCombos.emplace (combo, value);
     }
 }
 
@@ -831,7 +843,6 @@ void ShaderUnit::parseParameterConfiguration (
 	}
     } else if (type == "sampler2D" || type == "sampler2DComparison") {
 	const auto textureName = data.find ("default");
-	// TODO: CREATE TEXTURE WITH THE GIVEN COLOR
 	const auto requireany = data.find ("requireany");
 	const auto require = data.find ("require");
 	constexpr std::string_view prefix = "g_Texture";
@@ -843,7 +854,6 @@ void ShaderUnit::parseParameterConfiguration (
 	    sLog.error ("Cannot determine texture slot for ", name, " in shader ", this->m_file);
 	    return;
 	}
-	// TODO: SUPPORT USER TEXTURES!!
 
 	if (combo != data.end ()) {
 	    // TODO: CLEANUP HOW THIS IS DETERMINED FIRST

+ 39 - 9
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -740,14 +740,32 @@ std::optional<bool> CScene::getSoundPlayRequest (int id) const {
     return request == this->m_soundPlayRequests.end () ? std::nullopt : std::optional<bool> (request->second);
 }
 
+std::vector<CObject*> CScene::getLayers () const {
+    std::vector<CObject*> layers;
+
+    for (auto* object : this->m_objectsByRenderOrder) {
+	if (object != this->m_bloomObject) {
+	    layers.push_back (object);
+	}
+    }
+
+    return layers;
+}
+
 int CScene::getObjectIndex (const CObject* object) const {
-    const auto it = std::ranges::find (this->m_objectsByRenderOrder, object);
+    const auto layers = this->getLayers ();
+    const auto it = std::ranges::find (layers, object);
 
-    if (it == this->m_objectsByRenderOrder.end ()) {
+    if (it == layers.end ()) {
 	return -1;
     }
 
-    return static_cast<int> (std::distance (this->m_objectsByRenderOrder.begin (), it));
+    return static_cast<int> (std::distance (layers.begin (), it));
+}
+
+// bloom post-processes everything drawn before it, so it has to stay last
+void CScene::appendLayer (CObject* object) {
+    this->m_objectsByRenderOrder.insert (std::ranges::find (this->m_objectsByRenderOrder, this->m_bloomObject), object);
 }
 
 Render::CObject* CScene::createLayer (const std::string& imagePath) {
@@ -772,24 +790,30 @@ Render::CObject* CScene::createLayer (const std::string& imagePath) {
 	}
 
 	this->m_dynamicObjectData.emplace_back (std::move (objectData));
-	this->m_objectsByRenderOrder.push_back (renderObject);
+	this->appendLayer (renderObject);
 
 	return renderObject;
     };
 
     // createObject() throws on a missing asset, and unwinding a C++ exception through the QuickJS callback is undefined behavior
     try {
+	if (const auto cached = this->m_createLayerAliases.find (imagePath); cached != this->m_createLayerAliases.end ()) {
+	    return tryBuild (cached->second);
+	}
+
 	return tryBuild (imagePath);
     } catch (const std::exception& e) {
 	// scripts written against a workshop dependency's original layout use the un-prefixed name, try the prefixed copy first
 	if (const auto alias = this->getScene ().project.assetLocator->resolveWorkshopDependencyAlias (imagePath);
-	    alias.has_value ()) {
+	    alias.has_value () && !this->m_createLayerAliases.contains (imagePath)) {
 	    try {
 		sLog.out (
 		    "createLayer: '", imagePath, "' not found, found and using workshop-dependency copy '",
 		    alias->string (), "' instead"
 		);
-		return tryBuild (alias->string ());
+		auto* layer = tryBuild (alias->string ());
+		this->m_createLayerAliases.emplace (imagePath, alias->string ());
+		return layer;
 	    } catch (const std::exception& aliasError) {
 		sLog.error ("createLayer: workshop-dependency copy '", alias->string (), "' also failed: ", aliasError.what ());
 	    }
@@ -805,7 +829,7 @@ Render::CObject* CScene::createLayer (const std::string& imagePath) {
 
 	    this->m_objects.emplace (renderObject->getId (), renderObject);
 	    this->m_dynamicObjectData.emplace_back (std::move (objectData));
-	    this->m_objectsByRenderOrder.push_back (renderObject);
+	    this->appendLayer (renderObject);
 
 	    return renderObject;
 	} catch (const std::exception& placeholderError) {
@@ -824,8 +848,14 @@ void CScene::sortLayer (CObject* object, int index) {
 
     this->m_objectsByRenderOrder.erase (current);
 
-    const int clampedIndex = std::clamp (index, 0, static_cast<int> (this->m_objectsByRenderOrder.size ()));
-    this->m_objectsByRenderOrder.insert (this->m_objectsByRenderOrder.begin () + clampedIndex, object);
+    // index counts script-visible layers, so place it before whichever layer currently holds that slot
+    const auto layers = this->getLayers ();
+    const int clampedIndex = std::clamp (index, 0, static_cast<int> (layers.size ()));
+    const auto before = clampedIndex < static_cast<int> (layers.size ())
+	? std::ranges::find (this->m_objectsByRenderOrder, layers[clampedIndex])
+	: std::ranges::find (this->m_objectsByRenderOrder, this->m_bloomObject);
+
+    this->m_objectsByRenderOrder.insert (before, object);
 }
 
 void CScene::setAudioPolicy (bool muted, std::optional<int> ambientVolume) {

+ 4 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -60,6 +60,8 @@ public:
     [[nodiscard]] CObject* getObject (int id);
     /** True when any group above the object (through "parent") is hidden */
     [[nodiscard]] bool isHiddenByAncestor (const CObject& object) const;
+    /** The render order as scripts see it: without the synthesized bloom layer */
+    [[nodiscard]] std::vector<CObject*> getLayers () const;
     [[nodiscard]] int getObjectIndex (const CObject* object) const;
 
     void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
@@ -76,6 +78,7 @@ public:
     void sortLayer (CObject* object, int index);
 
 protected:
+    void appendLayer (CObject* object);
     void renderFrame (const glm::ivec4& viewport) override;
     void renderFrameSteps (const glm::ivec4& viewport);
     void updateMouse (const glm::ivec4& viewport);
@@ -112,6 +115,7 @@ private:
     // built from it (same pattern as m_bloomObjectData)
     std::vector<ObjectUniquePtr> m_dynamicObjectData = {};
     int m_nextDynamicLayerId = 2000000000;
+    std::map<std::string, std::string> m_createLayerAliases = {};
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionNormalized = {};

+ 2 - 2
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -346,13 +346,13 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
     auto* container = static_cast<OpaqueScriptableObjectAdapter*> (JS_GetAnyOpaque (obj_val, &classId));
 
     if (!container || container->magic != SCRIPTABLE_OPAQUE_MAGIC) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "scriptableobject_property_get: not a layer");
     }
 
     const char* name = JS_AtomToCString (ctx, atom);
 
     if (name == nullptr) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "scriptableobject_property_get: invalid property name");
     }
 
     ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });

+ 26 - 2
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -621,11 +621,11 @@ template JSValue vector_cross<3> (JSContext* ctx, JSValueConst this_val, int arg
 
 template <int components> JSValue vector_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     if (argc != 2) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "vector_mix: wrong number of arguments");
     }
 
     if (!JS_IsNumber (argv[1])) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "vector_mix: invalid argument");
     }
 
     double amount = 0.0f;
@@ -831,6 +831,26 @@ template JSValue vector_toString<2> (JSContext* ctx, JSValueConst this_val, int
 template JSValue vector_toString<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
 template JSValue vector_toString<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
 
+// the class is exotic with no own properties, JSON.stringify would otherwise produce "{}".
+// WE's vectors are plain JS objects, so they serialize as their components
+template <int components>
+JSValue vector_toJSON (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    static constexpr const char* names[] = { "x", "y", "z", "w" };
+    const auto value = vector_get<components> (container->value);
+    JSValue result = JS_NewObject (ctx);
+
+    for (int i = 0; i < components; i++) {
+	JS_SetPropertyStr (ctx, result, names[i], JS_NewFloat64 (ctx, value[i]));
+    }
+
+    return result;
+}
+
 template <int components>
 VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
     ObjectAdapter (engine), m_instanceId (++VectorAdapterInstanceId), m_name ("Vec" + std::to_string (components)),
@@ -941,6 +961,10 @@ VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
 	this->m_engine.getContext (), m_prototype, "toString",
 	JS_NewCFunction (this->m_engine.getContext (), vector_toString<components>, "toString", 0), JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "toJSON",
+	JS_NewCFunction (this->m_engine.getContext (), vector_toJSON<components>, "toJSON", 0), JS_PROP_ENUMERABLE
+    );
 
     JS_SetClassProto (this->m_engine.getContext (), this->m_classId, m_prototype);
 

+ 99 - 12
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -6,6 +6,7 @@
 #include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h"
 
 #include <ranges>
 
@@ -18,7 +19,8 @@ extern float g_Daytime;
 static uint32_t EngineInstanceId = 0;
 std::map<uint32_t, EngineObject&> engineInstances;
 
-JSValue engine_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+// read-only properties, writes are ignored instead of aborting the calling script
+JSValue engine_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_UNDEFINED; }
 
 // rebuilt on every read so scripts always see the current values
 JSValue engine_get_user_properties (
@@ -72,6 +74,59 @@ JSValue engine_get_canvas_size (JSContext* ctx, JSValueConst this_val, int argc,
     return it->second.getEngine ().getAdapters ().vec2->instantiate (const_cast<DynamicValue&> (size), true);
 }
 
+glm::vec2 engine_screen_size (EngineObject& engine) {
+    const auto& screen = engine.getScene ().getScreenSize ();
+
+    if (screen.x > 0 && screen.y > 0) {
+	return glm::vec2 (screen);
+    }
+
+    const auto& camera = engine.getScene ().getCamera ();
+    return { camera.getWidth (), camera.getHeight () };
+}
+
+JSValue engine_get_screen_resolution (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic
+) {
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    const DynamicValue size (engine_screen_size (it->second));
+
+    return it->second.getEngine ().getAdapters ().vec2->instantiate (const_cast<DynamicValue&> (size), true);
+}
+
+// magic packs the instance id with the question: bit 0 set asks for landscape instead of portrait
+JSValue engine_query_orientation (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    const auto it = engineInstances.find (magic >> 1);
+
+    if (it == engineInstances.end ()) {
+	return JS_FALSE;
+    }
+
+    const auto size = engine_screen_size (it->second);
+
+    return JS_NewBool (ctx, (magic & 1) ? size.x >= size.y : size.y > size.x);
+}
+
+// WE's version of this is the callback behind the stop functions setTimeout/setInterval return;
+// called straight off engine it has no timer bound to it and never stops anything
+JSValue engine_clear_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_FALSE;
+}
+
+// layers are never destroyed from scripts here, so any layer handle is still valid
+JSValue engine_is_object_valid (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc < 1) {
+	return JS_FALSE;
+    }
+
+    return JS_NewBool (ctx, WallpaperEngine::Scripting::Adapters::ScriptableObjectAdapter::getObject (argv[0]) != nullptr);
+}
+
 JSValue engine_get_frametime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     return JS_NewFloat64 (ctx, g_Time - g_TimeLast);
 }
@@ -88,13 +143,13 @@ JSValue engine_stop_interval (
     JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
 ) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_stop_interval: wrong number of arguments");
     }
 
     const auto it = engineInstances.find (magic);
 
     if (it == engineInstances.end ()) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_stop_interval: engine instance is gone");
     }
 
     int id = 0;
@@ -110,13 +165,13 @@ JSValue engine_stop_timeout (
     JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
 ) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_stop_timeout: wrong number of arguments");
     }
 
     const auto it = engineInstances.find (magic);
 
     if (it == engineInstances.end ()) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_stop_timeout: engine instance is gone");
     }
 
     int id = 0;
@@ -130,7 +185,7 @@ JSValue engine_stop_timeout (
 
 JSValue engine_set_interval (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc < 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_interval: wrong number of arguments");
     }
 
     int delay = 0;
@@ -142,13 +197,13 @@ JSValue engine_set_interval (JSContext* ctx, JSValueConst this_val, int argc, JS
     JSValue function = argv[0];
 
     if (!JS_IsFunction (ctx, function)) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_interval: expected a function");
     }
 
     const auto it = engineInstances.find (magic);
 
     if (it == engineInstances.end ()) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_interval: engine instance is gone");
     }
 
     int id = it->second.reserveNextIntervalId (function, delay);
@@ -241,7 +296,7 @@ JSValue engine_register_audio_buffers (JSContext* ctx, JSValueConst this_val, in
 
 JSValue engine_set_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc < 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_timeout: wrong number of arguments");
     }
 
     int delay = 0;
@@ -253,13 +308,13 @@ JSValue engine_set_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSV
     JSValue function = argv[0];
 
     if (!JS_IsFunction (ctx, function)) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_timeout: expected a function");
     }
 
     const auto it = engineInstances.find (magic);
 
     if (it == engineInstances.end ()) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "engine_set_timeout: engine instance is gone");
     }
 
     int id = it->second.reserveNextTimeoutId (function, delay);
@@ -374,7 +429,39 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	    JS_PROP_ENUMERABLE
 	);
     }
-    // TODO: ADD THE REST OF THE DEFINITION!
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "screenResolution"),
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_get_screen_resolution, "get", 0, JS_CFUNC_generic_magic,
+	    this->m_instanceId
+	),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "isPortrait",
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_query_orientation, "isPortrait", 0, JS_CFUNC_generic_magic,
+	    static_cast<int> (this->m_instanceId << 1)
+	),
+	JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "isLandscape",
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_query_orientation, "isLandscape", 0, JS_CFUNC_generic_magic,
+	    static_cast<int> ((this->m_instanceId << 1) | 1)
+	),
+	JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "clearTimeout",
+	JS_NewCFunction (this->m_engine.getContext (), engine_clear_timeout, "clearTimeout", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "isObjectValid",
+	JS_NewCFunction (this->m_engine.getContext (), engine_is_object_valid, "isObjectValid", 1),
+	JS_PROP_ENUMERABLE
+    );
 }
 
 EngineObject::~EngineObject () {

+ 2 - 1
src/WallpaperEngine/Scripting/InputObject.cpp

@@ -43,7 +43,8 @@ JSValue get_cursor_left_down (JSContext* ctx, JSValueConst this_val, int argc, J
     return JS_NewBool (ctx, input->getScene ().isCursorLeftDown ());
 }
 
-JSValue input_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+// read-only properties, writes are ignored instead of aborting the calling script
+JSValue input_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_UNDEFINED; }
 
 InputObject::InputObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
     m_scene (scene), m_engine (engine), m_classId (0) {

+ 10 - 10
src/WallpaperEngine/Scripting/Modules/ColorModule.cpp

@@ -34,11 +34,11 @@ int wecolor_init (JSContext* ctx, JSModuleDef* m) {
 
 JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_rgb2hsv: wrong number of arguments");
     }
 
     if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_rgb2hsv: invalid argument");
     }
 
     JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
@@ -98,11 +98,11 @@ JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValu
 
 JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_hsv2rgb: wrong number of arguments");
     }
 
     if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_hsv2rgb: invalid argument");
     }
 
     JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
@@ -165,11 +165,11 @@ JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValu
 
 JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: wrong number of arguments");
     }
 
     if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: invalid argument");
     }
 
     const auto it = colorModules.find (magic);
@@ -183,7 +183,7 @@ JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc,
     JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
 
     if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: invalid argument");
     }
 
     double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
@@ -203,11 +203,11 @@ JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc,
 
 JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 1) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: wrong number of arguments");
     }
 
     if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: invalid argument");
     }
 
     const auto it = colorModules.find (magic);
@@ -221,7 +221,7 @@ JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JS
     JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
 
     if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: invalid argument");
     }
 
     double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;

+ 4 - 4
src/WallpaperEngine/Scripting/Modules/MathModule.cpp

@@ -28,11 +28,11 @@ int wemath_init (JSContext* ctx, JSModuleDef* m) {
 
 JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 3) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wemath_smoothstep: wrong number of arguments");
     }
 
     if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wemath_smoothstep: invalid argument");
     }
 
     double edge0 = 0.0f;
@@ -48,11 +48,11 @@ JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSVa
 
 JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     if (argc != 3) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wemath_mix: wrong number of arguments");
     }
 
     if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "wemath_mix: invalid argument");
     }
 
     double a = 0.0f;

+ 158 - 45
src/WallpaperEngine/Scripting/SceneObject.cpp

@@ -22,13 +22,13 @@ JSValue get_bloom (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst
 JSValue get_bloomstrength (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     auto* container = get_opaque (this_val);
 
-    return JS_NewInt32 (ctx, container->getScene ().getScene ().camera.bloom.strength->value->getInt ());
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.bloom.strength->value->getFloat ());
 }
 
 JSValue get_bloomthreshold (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     auto* container = get_opaque (this_val);
 
-    return JS_NewInt32 (ctx, container->getScene ().getScene ().camera.bloom.threshold->value->getInt ());
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.bloom.threshold->value->getFloat ());
 }
 
 JSValue get_clearenabled (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
@@ -57,7 +57,7 @@ JSValue get_skylightcolor (JSContext* ctx, JSValueConst this_val, int argc, JSVa
     auto* container = get_opaque (this_val);
 
     return container->getEngine ().getAdapters ().vec3->instantiate (
-	*container->getScene ().getScene ().colors.ambient->value
+	*container->getScene ().getScene ().colors.skylight->value
     );
 }
 
@@ -174,32 +174,42 @@ JSValue instantiate_sound_layer (JSContext* ctx, WallpaperEngine::Render::Wallpa
     return handle;
 }
 
+JSValue instantiate_layer (SceneObject& container, WallpaperEngine::Render::CObject* object) {
+    if (object == nullptr || !object->is<ScriptableObject> ()) {
+	return JS_UNDEFINED;
+    }
+
+    return container.getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
+}
+
+JSValue get_layer_by_id (SceneObject& container, int id) {
+    return instantiate_layer (container, container.getScene ().getObject (id));
+}
+
+// real WE: a number is a render-order index, a string is a name and falls back to an id,
+// a layer is handed back as is, anything else is undefined
 JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_EXCEPTION;
+    if (argc < 1) {
+	return JS_UNDEFINED;
     }
 
     auto* container = get_opaque (this_val);
-
     JSValue layer = argv[0];
 
     if (JS_IsNumber (layer)) {
-	int id = 0;
-
-	JS_ToInt32 (ctx, &id, layer);
+	int index = 0;
+	JS_ToInt32 (ctx, &index, layer);
 
-	auto* object = container->getScene ().getObject (id);
+	const auto layers = container->getScene ().getLayers ();
 
-	if (object == nullptr) {
+	if (index < 0 || index >= static_cast<int> (layers.size ())) {
 	    return JS_UNDEFINED;
 	}
 
-	if (!object->is<ScriptableObject> ()) {
-	    return JS_UNDEFINED;
-	}
+	return instantiate_layer (*container, layers[index]);
+    }
 
-	return container->getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
-    } else if (JS_IsString (layer)) {
+    if (JS_IsString (layer)) {
 	const char* result = JS_ToCString (ctx, layer);
 
 	if (result == nullptr) {
@@ -215,23 +225,58 @@ JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst
 	}
 
 	for (auto object : container->getScene ().getObjectsByRenderOrder ()) {
-	    if (object->getObject ().name != result) {
-		continue;
+	    if (object->getObject ().name == result && object->is<ScriptableObject> ()) {
+		return instantiate_layer (*container, object);
 	    }
+	}
 
-	    if (!object->is<ScriptableObject> ()) {
-		continue;
-	    }
+	char* end = nullptr;
+	const long id = std::strtol (result, &end, 10);
 
-	    return container->getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
+	if (end != result && *end == '\0') {
+	    return get_layer_by_id (*container, static_cast<int> (id));
 	}
 
-	// No match: return undefined like the by-id lookup above, not JS_EXCEPTION without JS_Throw -
-	// that combo can't be caught by scripts and broke `if (thisScene.getLayer(name)) {...}` guards.
 	return JS_UNDEFINED;
     }
 
-    return JS_EXCEPTION;
+    if (WallpaperEngine::Scripting::Adapters::ScriptableObjectAdapter::getObject (layer) != nullptr) {
+	return JS_DupValue (ctx, layer);
+    }
+
+    return JS_UNDEFINED;
+}
+
+JSValue get_layer_by_id_call (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc < 1) {
+	return JS_UNDEFINED;
+    }
+
+    int id = 0;
+
+    if (JS_ToInt32 (ctx, &id, argv[0]) != 0) {
+	return JS_EXCEPTION;
+    }
+
+    return get_layer_by_id (*get_opaque (this_val), id);
+}
+
+JSValue get_layer_count (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_NewInt32 (ctx, static_cast<int> (get_opaque (this_val)->getScene ().getLayers ().size ()));
+}
+
+JSValue enumerate_layers (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+    JSValue result = JS_NewArray (ctx);
+    uint32_t count = 0;
+
+    for (auto* object : container->getScene ().getLayers ()) {
+	if (object->is<ScriptableObject> ()) {
+	    JS_SetPropertyUint32 (ctx, result, count++, instantiate_layer (*container, object));
+	}
+    }
+
+    return result;
 }
 
 JSValue get_layer_index (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
@@ -292,7 +337,63 @@ JSValue sort_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueCons
     return JS_UNDEFINED;
 }
 
-JSValue scene_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+// magic is the property's index in the order SceneObject's constructor defines them
+UserSetting* scene_setting (const Scene& scene, int magic) {
+    switch (magic) {
+	case 0:
+	    return scene.camera.bloom.enabled.get ();
+	case 1:
+	    return scene.camera.bloom.strength.get ();
+	case 2:
+	    return scene.camera.bloom.threshold.get ();
+	case 4:
+	    return scene.colors.clear.get ();
+	case 5:
+	    return scene.colors.ambient.get ();
+	case 6:
+	    return scene.colors.skylight.get ();
+	case 7:
+	    return scene.camera.projection.fov.get ();
+	case 8:
+	    return scene.camera.projection.nearz.get ();
+	case 9:
+	    return scene.camera.projection.farz.get ();
+	case 10:
+	    return scene.camera.fade.get ();
+	case 11:
+	    return scene.camera.shake.enabled.get ();
+	case 12:
+	    return scene.camera.shake.speed.get ();
+	case 13:
+	    return scene.camera.shake.amplitude.get ();
+	case 14:
+	    return scene.camera.shake.roughness.get ();
+	case 15:
+	    return scene.camera.parallax.enabled.get ();
+	case 16:
+	    return scene.camera.parallax.amount.get ();
+	case 17:
+	    return scene.camera.parallax.delay.get ();
+	case 18:
+	    return scene.camera.parallax.mouseInfluence.get ();
+	default:
+	    return nullptr;
+    }
+}
+
+JSValue scene_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    auto* container = get_opaque (this_val);
+    auto* setting = scene_setting (container->getScene ().getScene (), magic);
+
+    // clearenabled has no backing value, the write is dropped like any other unsupported scene setting
+    if (argc < 1 || setting == nullptr || setting->value == nullptr) {
+	return JS_UNDEFINED;
+    }
+
+    container->getEngine ().assignJsValue (argv[0], *setting->value);
+
+    return JS_UNDEFINED;
+}
 
 SceneObject::SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
     m_scene (scene), m_engine (engine), m_classId (0) {
@@ -307,107 +408,119 @@ SceneObject::SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scen
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloom"),
 	JS_NewCFunction (this->m_engine.getContext (), get_bloom, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 0), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloomstrength"),
 	JS_NewCFunction (this->m_engine.getContext (), get_bloomstrength, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 1), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloomthreshold"),
 	JS_NewCFunction (this->m_engine.getContext (), get_bloomthreshold, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 2), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "clearenabled"),
 	JS_NewCFunction (this->m_engine.getContext (), get_clearenabled, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 3), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "clearcolor"),
 	JS_NewCFunction (this->m_engine.getContext (), get_clearcolor, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 4), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "ambientcolor"),
 	JS_NewCFunction (this->m_engine.getContext (), get_ambientcolor, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 5), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "skylightcolor"),
 	JS_NewCFunction (this->m_engine.getContext (), get_skylightcolor, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 6), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "fov"),
 	JS_NewCFunction (this->m_engine.getContext (), get_fov, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 7), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "nearz"),
 	JS_NewCFunction (this->m_engine.getContext (), get_nearz, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 8), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "farz"),
 	JS_NewCFunction (this->m_engine.getContext (), get_farz, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 9), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerafade"),
 	JS_NewCFunction (this->m_engine.getContext (), get_camerafade, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 10), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerashake"),
 	JS_NewCFunction (this->m_engine.getContext (), get_camerashake, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 11), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerashakespeed"),
 	JS_NewCFunction (this->m_engine.getContext (), get_camerashakespeed, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 12), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance,
 	JS_NewAtom (this->m_engine.getContext (), "camerashakeamplitude"),
 	JS_NewCFunction (this->m_engine.getContext (), get_camerashakeamplitude, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 13), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance,
 	JS_NewAtom (this->m_engine.getContext (), "camerashakeroughness"),
 	JS_NewCFunction (this->m_engine.getContext (), get_camerashakeroughness, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 14), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "cameraparallax"),
 	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallax, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 15), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance,
 	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxamount"),
 	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxamount, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 16), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance,
 	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxdelay"),
 	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxdelay, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 17), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance,
 	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxmouseinfluence"),
 	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxmouseinfluence, "get", 0),
-	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scene_set_value, "set", 1, JS_CFUNC_generic_magic, 18), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "getLayer",
 	JS_NewCFunction (this->m_engine.getContext (), get_layer, "getLayer", 1), JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "getLayerByID",
+	JS_NewCFunction (this->m_engine.getContext (), get_layer_by_id_call, "getLayerByID", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "getLayerCount",
+	JS_NewCFunction (this->m_engine.getContext (), get_layer_count, "getLayerCount", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "enumerateLayers",
+	JS_NewCFunction (this->m_engine.getContext (), enumerate_layers, "enumerateLayers", 0), JS_PROP_ENUMERABLE
+    );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "getLayerIndex",
 	JS_NewCFunction (this->m_engine.getContext (), get_layer_index, "getLayerIndex", 1), JS_PROP_ENUMERABLE

+ 96 - 22
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -25,6 +25,7 @@
 #include <cerrno>
 #include <chrono>
 #include <cmath>
+#include <cstdio>
 #include <cstring>
 #include <ctime>
 #include <future>
@@ -316,28 +317,101 @@ ScriptEngine::~ScriptEngine () {
     this->m_adapters.object.reset ();
 }
 
-void WallpaperEngine::Scripting::logJSException (JSContext* ctx, const char* context) {
+namespace {
+// the line the first stack frame inside `context` points at, with a caret under the column
+std::string sourceExcerpt (const std::string& stack, const char* context, const std::string& source) {
+    const std::string marker = std::string (context) + ":";
+    const auto at = stack.find (marker);
+
+    if (at == std::string::npos) {
+	return {};
+    }
+
+    int line = 0;
+    int column = 0;
+
+    if (std::sscanf (stack.c_str () + at + marker.size (), "%d:%d", &line, &column) < 1 || line < 1) {
+	return {};
+    }
+
+    size_t begin = 0;
+
+    for (int i = 1; i < line; i++) {
+	begin = source.find ('\n', begin);
+
+	if (begin == std::string::npos) {
+	    return {};
+	}
+
+	begin++;
+    }
+
+    const auto lineEnd = source.find ('\n', begin);
+    std::string text = source.substr (begin, lineEnd == std::string::npos ? std::string::npos : lineEnd - begin);
+    std::ranges::replace (text, '\t', ' ');
+
+    std::string result = std::to_string (line) + ": " + text;
+
+    if (column > 0) {
+	result += "\n" + std::string (std::to_string (line).size () + 2 + column - 1, ' ') + "^";
+    }
+
+    return result;
+}
+} // namespace
+
+void WallpaperEngine::Scripting::logJSException (
+    JSContext* ctx, const char* context, const std::optional<std::string>& source
+) {
+    // scripts that fail every tick would otherwise print the same error every frame
+    static std::map<std::string, std::string> lastReported;
+
     JSValue exc = JS_GetException (ctx);
-    if (!JS_IsNull (exc) && !JS_IsUndefined (exc)) {
-	const char* str = JS_ToCString (ctx, exc);
-	if (str) {
-	    sLog.error ("ScriptEngine [", context, "]: ", str);
+    ScopeGuard freeException ([=] { JS_FreeValue (ctx, exc); });
+
+    if (JS_IsNull (exc) || JS_IsUndefined (exc) || JS_IsUninitialized (exc)) {
+	sLog.error ("ScriptEngine [", context, "]: a native call failed without setting an exception");
+	return;
+    }
+
+    std::string message;
+    std::string stack;
+
+    if (const char* str = JS_ToCString (ctx, exc); str != nullptr) {
+	message = str;
+	JS_FreeCString (ctx, str);
+    }
+
+    JSValue stackValue = JS_GetPropertyStr (ctx, exc, "stack");
+
+    if (!JS_IsUndefined (stackValue)) {
+	if (const char* str = JS_ToCString (ctx, stackValue); str != nullptr) {
+	    stack = str;
 	    JS_FreeCString (ctx, str);
 	}
+    }
 
-	JSValue stack = JS_GetPropertyStr (ctx, exc, "stack");
-	if (!JS_IsUndefined (stack)) {
-	    const char* stackStr = JS_ToCString (ctx, stack);
-	    if (stackStr && stackStr[0] != '\0') {
-		sLog.error ("ScriptEngine [", context, "] stack: ", stackStr);
-	    }
-	    if (stackStr) {
-		JS_FreeCString (ctx, stackStr);
-	    }
+    JS_FreeValue (ctx, stackValue);
+
+    auto& last = lastReported[context];
+
+    if (last == message + stack) {
+	return;
+    }
+
+    last = message + stack;
+
+    sLog.error ("ScriptEngine [", context, "]: ", message);
+
+    if (!stack.empty ()) {
+	sLog.error ("ScriptEngine [", context, "] stack: ", stack);
+    }
+
+    if (source.has_value ()) {
+	if (const auto excerpt = sourceExcerpt (stack, context, *source); !excerpt.empty ()) {
+	    sLog.error ("ScriptEngine [", context, "] source:\n", excerpt);
 	}
-	JS_FreeValue (ctx, stack);
     }
-    JS_FreeValue (ctx, exc);
 }
 
 void ScriptEngine::installBuiltins () {
@@ -652,7 +726,7 @@ void ScriptEngine::queueScript (
     );
 
     if (JS_IsException (compiledModule)) {
-	logJSException (this->m_context, key.c_str ());
+	logJSException (this->m_context, key.c_str (), source);
 	return;
     }
 
@@ -950,7 +1024,7 @@ void ScriptEngine::dispatchAnimationEvents () {
 	    JSValue result = this->call (module.module, 2, args, "animationEvent");
 
 	    if (JS_IsException (result)) {
-		logJSException (this->m_context, key.c_str ());
+		logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
 	    } else {
 		// like update(), the handler's return value becomes the property's new value
 		jsToDynamicValue (this->m_context, result, module.value);
@@ -975,7 +1049,7 @@ void ScriptEngine::initializeModule (const std::string& key, LoadedModule& modul
     JSValue initResult = this->call (module.module, 1, initArgs, "init");
 
     if (JS_IsException (initResult)) {
-	logJSException (this->m_context, key.c_str ());
+	logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
     } else if (
 	valueBeforeInit.getType () == module.value.getType () && valueBeforeInit.getVec4 () == module.value.getVec4 ()
 	&& valueBeforeInit.getString () == module.value.getString ()
@@ -997,7 +1071,7 @@ void ScriptEngine::initializeModule (const std::string& key, LoadedModule& modul
     JSValue applyResult = this->call (module.module, 1, applyArgs, "applyUserProperties");
 
     if (JS_IsException (applyResult)) {
-	logJSException (this->m_context, key.c_str ());
+	logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
     }
 
     JS_FreeValue (this->m_context, applyResult);
@@ -1070,7 +1144,7 @@ void ScriptEngine::dispatchCursorEvent (
 	JSValue result = this->call (module.module, 1, args, handler);
 
 	if (JS_IsException (result)) {
-	    logJSException (this->m_context, key.c_str ());
+	    logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
 	}
 
 	JS_FreeValue (this->m_context, result);
@@ -1156,7 +1230,7 @@ void ScriptEngine::tick () {
 	});
 
 	if (JS_IsException (result)) {
-	    logJSException (this->m_context, key.c_str ());
+	    logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
 	    continue;
 	}
 

+ 1 - 1
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -39,7 +39,7 @@ class GLPlayer;
 }
 
 namespace WallpaperEngine::Scripting {
-void logJSException (JSContext* ctx, const char* context);
+void logJSException (JSContext* ctx, const char* context, const std::optional<std::string>& source = std::nullopt);
 
 class ScriptPropertiesObject;
 namespace Adapters {

+ 27 - 6
src/WallpaperEngine/Scripting/resources/builtins.js

@@ -1,13 +1,34 @@
 globalThis.__intervals = Object.create(null);
+// same shape as scenescript64.dll's: values go through JSON like WE's on-disk store, a location
+// other than "global" (or none) means the per-screen store, and setting undefined deletes the key
 globalThis.localStorage = globalThis.localStorage || {
-  __data: Object.create(null),
-  get(key) {
+  LOCATION_GLOBAL: 'global',
+  LOCATION_SCREEN: 'screen',
+  __stores: { global: Object.create(null), screen: Object.create(null) },
+  __store(location) {
+    return String(location).toLowerCase() === 'global' ? this.__stores.global : this.__stores.screen;
+  },
+  get(key, location) {
+    const store = this.__store(location);
     key = String(key);
-    return Object.prototype.hasOwnProperty.call(this.__data, key) ? this.__data[key] : null;
+    return Object.prototype.hasOwnProperty.call(store, key) ? JSON.parse(store[key]) : undefined;
+  },
+  set(key, value, location) {
+    const serialized = value === undefined ? undefined : JSON.stringify(value);
+    if (serialized === undefined) {
+      this.delete(key, location);
+      return;
+    }
+    this.__store(location)[String(key)] = serialized;
   },
-  set(key, value) { this.__data[String(key)] = String(value); },
-  remove(key) { delete this.__data[String(key)]; },
-  clear() { this.__data = Object.create(null); }
+  delete(key, location) { delete this.__store(location)[String(key)]; },
+  clear(location) {
+    if (String(location).toLowerCase() === 'global') {
+      this.__stores.global = Object.create(null);
+    } else {
+      this.__stores.screen = Object.create(null);
+    }
+  }
 };
 globalThis.MediaPlaybackEvent = globalThis.MediaPlaybackEvent || {
   PLAYBACK_STOPPED: 0,

BIN
tools/regression/__pycache__/regress.cpython-313.pyc


+ 55 - 0
tools/regression/fixed_clock.c

@@ -0,0 +1,55 @@
+// LD_PRELOAD shim for regression renders: the wall clock (time(), gettimeofday(),
+// clock_gettime(CLOCK_REALTIME*)) always reads LWE_FIXED_CLOCK (unix seconds). That covers
+// the day/night uniform, text clocks, script Date objects and QuickJS's Math.random seed.
+// Monotonic clocks are left alone so frame pacing and timeouts keep working.
+#define _GNU_SOURCE
+#include <dlfcn.h>
+#include <stdlib.h>
+#include <sys/time.h>
+#include <time.h>
+
+static time_t fixed_seconds (void) {
+    static time_t value = -1;
+
+    if (value == -1) {
+	const char* env = getenv ("LWE_FIXED_CLOCK");
+	value = env != NULL ? (time_t) strtoll (env, NULL, 10) : 1767268800; // 2026-01-01 12:00 UTC
+    }
+
+    return value;
+}
+
+time_t time (time_t* out) {
+    const time_t now = fixed_seconds ();
+
+    if (out != NULL) {
+	*out = now;
+    }
+
+    return now;
+}
+
+int gettimeofday (struct timeval* tv, void* tz) {
+    if (tv != NULL) {
+	tv->tv_sec = fixed_seconds ();
+	tv->tv_usec = 0;
+    }
+
+    return 0;
+}
+
+int clock_gettime (clockid_t clock, struct timespec* ts) {
+    static int (*real) (clockid_t, struct timespec*) = NULL;
+
+    if (clock == CLOCK_REALTIME || clock == CLOCK_REALTIME_COARSE) {
+	ts->tv_sec = fixed_seconds ();
+	ts->tv_nsec = 0;
+	return 0;
+    }
+
+    if (real == NULL) {
+	real = (int (*) (clockid_t, struct timespec*)) dlsym (RTLD_NEXT, "clock_gettime");
+    }
+
+    return real (clock, ts);
+}

+ 681 - 0
tools/regression/regress.py

@@ -0,0 +1,681 @@
+#!/usr/bin/env python3
+"""Wallpaper regression runs: render every installed scene wallpaper headlessly with a build,
+then compare two runs (usually the last good build against the one being tested).
+
+    tools/regression/regress.py run /path/to/old/linux-wallpaperengine runs/base --repeat 2
+    tools/regression/regress.py run build/output/linux-wallpaperengine runs/new
+    tools/regression/regress.py compare runs/base runs/new --report runs/report
+
+Runs are reproducible: animation time advances by a fixed step per frame (LWE_FIXED_TIMESTEP),
+particles are seeded per object, the wall clock is pinned (fixed_clock.c) and audio input is
+off. Each worker gets its own Xvfb display, nothing touches the real screen.
+
+compare looks at four things per wallpaper: whether it still renders (crash/timeout), error
+lines that appear or disappear in the log, how much of the screenshot changed, and whether gif
+and video textures still move (wallpapers shipping them get a second render a few frames later,
+a hidden or frozen one shows up as less motion). Video textures play in real time and never
+render the same twice, --repeat 2 on the base run marks those pixels so compare ignores them.
+It writes an HTML report with side-by-sides of everything flagged and exits 1 if anything
+regressed. Both builds need LWE_FIXED_TIMESTEP support, older ones render every run differently.
+
+Needs Xvfb, gcc and Pillow (tools/requirements.txt).
+"""
+
+import argparse
+import concurrent.futures
+import html
+import json
+import os
+import queue
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+HERE = Path (__file__).resolve ().parent
+TOOLS = HERE.parent
+
+DEFAULT_WORKSHOP = Path.home () / '.local/share/Steam/steamapps/workshop/content/431960'
+DEFAULT_ASSETS = Path.home () / '.local/share/Steam/steamapps/common/wallpaper_engine/assets'
+
+# lines worth tracking between builds; everything else in the log is chatter
+ERROR_LINE = re.compile (
+    r'error|exception|failed|cannot|can\'t|unsupported|not supported|typeerror|referenceerror|'
+    r'syntaxerror|abort|segmentation|falling back',
+    re.IGNORECASE
+)
+# driver/X noise that shows up on some machines and says nothing about the engine
+IGNORED_LINE = re.compile (r'amdgpu|_XSERVTrans|MESA-LOADER|libEGL warning|ALSA lib|Puppet draw result')
+# mpv announcing a video stream, those textures play in real time and never render the same twice
+VIDEO_LINE = re.compile (r'^\W*Video\s+--vid=', re.MULTILINE)
+
+
+def env_path (name, fallback):
+    value = os.environ.get (name)
+    return Path (value) if value else fallback
+
+
+def read_project (folder):
+    try:
+        with open (folder / 'project.json', encoding = 'utf-8-sig') as f:
+            return json.load (f)
+    except (OSError, ValueError):
+        return None
+
+
+def read_sized (f):
+    size = int.from_bytes (f.read (4), 'little')
+    return f.read (size).decode ('utf-8', 'replace')
+
+
+def texture_kinds (header):
+    """'gif' for animated (sprite sheet) textures, 'video' for mp4 ones, from a .tex header."""
+    if header[:8] != b'TEXV0005' or len (header) < 26:
+        return set ()
+
+    flags = int.from_bytes (header[22:26], 'little')
+    return ({'gif'} if flags & 4 else set ()) | ({'video'} if flags & 32 else set ())
+
+
+def animated_textures (folder):
+    """What kind of animated textures a wallpaper ships, loose or inside its .pkg files."""
+    kinds = set ()
+
+    for path in folder.rglob ('*'):
+        if path.suffix == '.mp4':
+            kinds.add ('video')
+        elif path.suffix == '.tex':
+            with open (path, 'rb') as f:
+                kinds |= texture_kinds (f.read (64))
+        elif path.suffix == '.pkg':
+            try:
+                with open (path, 'rb') as f:
+                    read_sized (f)
+                    entries = []
+                    for _ in range (int.from_bytes (f.read (4), 'little')):
+                        name = read_sized (f)
+                        offset = int.from_bytes (f.read (4), 'little')
+                        f.read (4)
+                        entries.append ((name, offset))
+                    base = f.tell ()
+                    for name, offset in entries:
+                        if name.endswith ('.mp4'):
+                            kinds.add ('video')
+                        elif name.endswith ('.tex'):
+                            f.seek (base + offset)
+                            kinds |= texture_kinds (f.read (64))
+            except (OSError, ValueError):
+                pass
+
+    return kinds
+
+
+def find_wallpapers (workshop, types, ids, variants):
+    """(name, folder, title, properties) per render; variants add renders of the same wallpaper
+    with user properties set, named <id>@<variant>."""
+    found = []
+
+    for folder in sorted (workshop.iterdir ()):
+        if not folder.is_dir () or (ids and folder.name not in ids):
+            continue
+
+        project = read_project (folder)
+        kind = str ((project or {}).get ('type', '')).lower ()
+
+        if kind in types:
+            title = (project or {}).get ('title', '')
+            found.append ((folder.name, folder, title, []))
+
+            for variant, properties in variants.get (folder.name, {}).items ():
+                found.append ((f'{folder.name}@{variant}', folder, f'{title} [{variant}]', properties))
+
+    return found
+
+
+def build_shims (out):
+    shims = out / '.shims'
+    shims.mkdir (parents = True, exist_ok = True)
+    built = []
+
+    for source in (TOOLS / 'dbus_noop_shim.c', HERE / 'fixed_clock.c'):
+        target = shims / (source.stem + '.so')
+
+        if not target.exists () or source.stat ().st_mtime > target.stat ().st_mtime:
+            subprocess.run (['gcc', '-shared', '-fPIC', '-O2', '-o', str (target), str (source), '-ldl'], check = True)
+
+        built.append (str (target))
+
+    return ':'.join (built)
+
+
+def x_sockets ():
+    """Display numbers with a listening X socket, abstract ones included (some sandboxes can't
+    write /tmp/.X11-unix, Xvfb then only has the abstract socket)."""
+    numbers = set ()
+
+    with open ('/proc/net/unix') as f:
+        for line in f:
+            match = re.search (r'/tmp/\.X11-unix/X(\d+)$', line.strip ())
+            if match:
+                numbers.add (int (match.group (1)))
+
+    return numbers
+
+
+class Display:
+    """One Xvfb server per worker so renders can run side by side."""
+
+    def __init__ (self, first, width, height):
+        number = first
+
+        while True:
+            while number in x_sockets () or Path (f'/tmp/.X11-unix/X{number}').exists ():
+                number += 1
+
+            self.name = f':{number}'
+            self.number = number
+            self.process = subprocess.Popen (
+                ['Xvfb', self.name, '-screen', '0', f'{width}x{height}x24', '-nolisten', 'tcp'],
+                stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL
+            )
+
+            for _ in range (100):
+                if number in x_sockets ():
+                    return
+                if self.process.poll () is not None:
+                    break
+                time.sleep (0.05)
+
+            self.close ()
+            number += 1
+
+            if number > first + 50:
+                raise RuntimeError ('could not start Xvfb')
+
+    def close (self):
+        self.process.terminate ()
+        try:
+            self.process.wait (5)
+        except subprocess.TimeoutExpired:
+            self.process.kill ()
+
+
+def stop (process):
+    if process.poll () is not None:
+        return
+
+    os.killpg (process.pid, signal.SIGTERM)
+    try:
+        process.wait (5)
+    except subprocess.TimeoutExpired:
+        os.killpg (process.pid, signal.SIGKILL)
+        process.wait ()
+
+
+def render_once (args, env, folder, shot, log_path, properties):
+    command = [
+        str (args.binary), '--window', f'0x0x{args.width}x{args.height}', '--fps', '1000',
+        '--silent', '--no-audio-processing', '--screenshot', str (shot), '--screenshot-delay', str (args.frame),
+        '--assets-dir', str (args.assets),
+    ]
+
+    for value in properties:
+        command += ['--set-property', value]
+
+    command.append (str (folder))
+
+    started = time.monotonic ()
+    status = 'timeout'
+
+    with open (log_path, 'wb') as log:
+        process = subprocess.Popen (
+            command, env = env, stdin = subprocess.DEVNULL, stdout = log, stderr = subprocess.STDOUT, start_new_session = True
+        )
+        last_size = -1
+
+        while time.monotonic () - started < args.timeout:
+            code = process.poll ()
+            size = shot.stat ().st_size if shot.exists () else 0
+
+            # the engine keeps running after the screenshot, stop it once the file is complete
+            if size > 0 and size == last_size:
+                status = 'ok'
+                break
+
+            if code is not None:
+                status = 'ok' if size > 0 else ('crash' if code < 0 else 'noshot')
+                break
+
+            last_size = size
+            time.sleep (0.25)
+
+        stop (process)
+
+    return status, process.returncode, time.monotonic () - started
+
+
+def render (args, preload, display, wallpaper):
+    wallpaper_id, folder, title, properties = wallpaper
+    target = args.out / wallpaper_id
+    shutil.rmtree (target, ignore_errors = True)
+    target.mkdir (parents = True)
+
+    env = dict (os.environ)
+    env.pop ('WAYLAND_DISPLAY', None)
+    env.pop ('WAYLAND_SOCKET', None)
+    env.update ({
+        'DISPLAY': display.name,
+        'XDG_SESSION_TYPE': 'x11',
+        'LD_LIBRARY_PATH': ':'.join (filter (None, [str (args.binary.parent), os.environ.get ('LD_LIBRARY_PATH')])),
+        'LD_PRELOAD': preload,
+        'LWE_FIXED_TIMESTEP': str (args.step),
+        'LWE_FIXED_CLOCK': str (args.clock),
+        'TZ': 'UTC',
+    })
+    # software rendering (llvmpipe) starts a thread per core in every instance, which just
+    # thrashes when several renders run at once
+    env.setdefault ('LP_NUM_THREADS', str (max (1, (os.cpu_count () or 1) // args.jobs)))
+
+    status, code, seconds = render_once (args, env, folder, target / 'shot.png', target / 'log.txt', properties)
+
+    # extra renders of the same build show what isn't reproducible (video textures play in real
+    # time), compare ignores those pixels
+    if status == 'ok':
+        for repeat in range (2, args.repeat + 1):
+            render_once (args, env, folder, target / f'shot{repeat}.png', target / f'log{repeat}.txt', properties)
+
+    # animated textures have to keep moving: a second, later frame shows whether a gif or video
+    # is still drawn and still playing (a hidden or frozen one leaves its area static)
+    animated = sorted (animated_textures (folder))
+    motion = None
+
+    if status == 'ok' and animated and args.motion_gap > 0:
+        later = target / 'shot_motion.png'
+        args_later = argparse.Namespace (**{**vars (args), 'frame': args.frame + args.motion_gap})
+
+        if render_once (args_later, env, folder, later, target / 'log_motion.txt', properties)[0] == 'ok':
+            motion = changed_fraction (target / 'shot.png', later, 16)
+
+    log = (target / 'log.txt').read_text (errors = 'replace')
+    result = {
+        'id': wallpaper_id,
+        'title': title,
+        'status': status,
+        'exit': code,
+        'seconds': round (seconds, 1),
+        'video': bool (VIDEO_LINE.search (log)),
+        'animated': animated,
+        'motion': motion,
+    }
+    (target / 'result.json').write_text (json.dumps (result, ensure_ascii = False, indent = 1))
+
+    return result
+
+
+def cmd_run (args):
+    args.binary = args.binary.resolve ()
+    args.out = args.out.resolve ()
+    args.out.mkdir (parents = True, exist_ok = True)
+
+    variants = {}
+    if args.variants.exists ():
+        variants = {k: v for k, v in json.loads (args.variants.read_text ()).items () if not k.startswith ('_')}
+
+    wallpapers = find_wallpapers (args.workshop, set (args.types.split (',')), set (args.ids or []), variants)
+
+    if not wallpapers:
+        sys.exit (f'no wallpapers found in {args.workshop}')
+
+    preload = build_shims (args.out)
+    displays = queue.Queue ()
+    opened = []
+
+    try:
+        for _ in range (args.jobs):
+            display = Display (opened[-1].number + 1 if opened else 90, args.width, args.height)
+            opened.append (display)
+            displays.put (display)
+
+        def job (wallpaper):
+            display = displays.get ()
+            try:
+                return render (args, preload, display, wallpaper)
+            finally:
+                displays.put (display)
+
+        (args.out / 'run.json').write_text (json.dumps ({
+            'binary': str (args.binary),
+            'frame': args.frame,
+            'step': args.step,
+            'clock': args.clock,
+            'size': [args.width, args.height],
+            'repeat': args.repeat,
+            'started': time.strftime ('%Y-%m-%d %H:%M:%S'),
+        }, indent = 1))
+
+        done = 0
+
+        with concurrent.futures.ThreadPoolExecutor (args.jobs) as pool:
+            for result in pool.map (job, wallpapers):
+                done += 1
+                print (f'[{done}/{len (wallpapers)}] {result["id"]} {result["status"]} {result["seconds"]}s', flush = True)
+    finally:
+        for display in opened:
+            display.close ()
+
+
+def error_lines (log):
+    if not log.exists ():
+        return set ()
+
+    lines = set ()
+
+    for line in log.read_text (errors = 'replace').splitlines ():
+        if ERROR_LINE.search (line) and not IGNORED_LINE.search (line):
+            # numbers, pointers and temp paths change between runs, the message doesn't
+            line = re.sub (r'0x[0-9a-fA-F]+', 'X', line)
+            line = re.sub (r'\d+', 'N', line)
+            lines.add (line.strip ()[:300])
+
+    return lines
+
+
+def strongest_difference (a, b):
+    from PIL import ImageChops
+
+    if a.size != b.size:
+        b = b.resize (a.size)
+
+    # per pixel, the largest channel difference
+    channels = ImageChops.difference (a, b).split ()
+    return ImageChops.lighter (ImageChops.lighter (channels[0], channels[1]), channels[2])
+
+
+def changed_fraction (first, second, threshold):
+    from PIL import Image
+
+    a = Image.open (first).convert ('RGB')
+    histogram = strongest_difference (a, Image.open (second).convert ('RGB')).histogram ()
+    return sum (histogram[threshold + 1:]) / (a.size[0] * a.size[1])
+
+
+def noise_mask (shots, threshold):
+    """Pixels that already differ between repeated renders of the same build."""
+    from PIL import Image, ImageChops
+
+    images = [Image.open (shot).convert ('RGB') for shot in shots]
+    mask = None
+
+    for other in images[1:]:
+        changed = strongest_difference (images[0], other).point (lambda v: 255 if v > threshold else 0)
+        mask = changed if mask is None else ImageChops.lighter (mask, changed)
+
+    return mask
+
+
+def image_diff (base, new, threshold, heatmap, noise):
+    from PIL import Image, ImageChops
+
+    a = Image.open (base).convert ('RGB')
+    b = Image.open (new).convert ('RGB')
+    changed_mask = strongest_difference (a, b).point (lambda v: 255 if v > threshold else 0)
+    total = a.size[0] * a.size[1]
+    ignored = 0
+
+    if noise is not None:
+        noise = noise.resize (a.size)
+        ignored = noise.histogram ()[255] / total
+        changed_mask = ImageChops.subtract (changed_mask, noise)
+
+    changed = changed_mask.histogram ()[255] / total
+
+    if changed > 0 or ignored > 0:
+        dim = a.convert ('L').convert ('RGB').point (lambda v: v // 3)
+        if noise is not None:
+            dim = Image.composite (Image.new ('RGB', a.size, (60, 60, 110)), dim, noise)
+        Image.composite (Image.new ('RGB', a.size, (255, 40, 40)), dim, changed_mask).save (heatmap, quality = 85)
+
+    return changed, ignored
+
+
+def thumbnail (source, target, width = 640):
+    from PIL import Image
+
+    image = Image.open (source).convert ('RGB')
+    image.thumbnail ((width, width))
+    image.save (target, quality = 85)
+
+
+def load_result (folder):
+    try:
+        return json.loads ((folder / 'result.json').read_text ())
+    except (OSError, ValueError):
+        return None
+
+
+def cmd_compare (args):
+    report = args.report.resolve ()
+    shutil.rmtree (report, ignore_errors = True)
+    (report / 'img').mkdir (parents = True)
+
+    ids = sorted ({p.name for p in args.base.iterdir () if (p / 'result.json').exists ()}
+                  | {p.name for p in args.new.iterdir () if (p / 'result.json').exists ()})
+    rows = []
+
+    for wallpaper_id in ids:
+        base = load_result (args.base / wallpaper_id)
+        new = load_result (args.new / wallpaper_id)
+        row = {
+            'id': wallpaper_id,
+            'title': (new or base or {}).get ('title', ''),
+            'base': base['status'] if base else 'missing',
+            'new': new['status'] if new else 'missing',
+            'added_errors': [],
+            'fixed_errors': [],
+            'changed': None,
+            'verdict': 'same',
+        }
+
+        if base and new:
+            base_errors = error_lines (args.base / wallpaper_id / 'log.txt')
+            new_errors = error_lines (args.new / wallpaper_id / 'log.txt')
+            row['added_errors'] = sorted (new_errors - base_errors)
+            row['fixed_errors'] = sorted (base_errors - new_errors)
+
+        base_shot = args.base / wallpaper_id / 'shot.png'
+        new_shot = args.new / wallpaper_id / 'shot.png'
+
+        row['video'] = bool ((base or {}).get ('video') or (new or {}).get ('video'))
+        row['ignored'] = 0
+
+        if base and new and base_shot.exists () and new_shot.exists ():
+            heatmap = report / 'img' / f'{wallpaper_id}_diff.jpg'
+            repeats = sorted ((args.base / wallpaper_id).glob ('shot[0-9]*.png'))
+            repeats += sorted ((args.new / wallpaper_id).glob ('shot[0-9]*.png'))
+            noise = None
+
+            if repeats:
+                noise = noise_mask ([base_shot] + [r for r in repeats if r.parent == base_shot.parent], args.threshold)
+                new_repeats = [r for r in repeats if r.parent == new_shot.parent]
+                if new_repeats:
+                    from PIL import ImageChops
+                    extra = noise_mask ([new_shot] + new_repeats, args.threshold)
+                    noise = extra if noise is None else ImageChops.lighter (noise, extra.resize (noise.size))
+
+            row['changed'], row['ignored'] = image_diff (base_shot, new_shot, args.threshold, heatmap, noise)
+
+        row['animated'] = (new or base or {}).get ('animated', [])
+        row['motion'] = [(base or {}).get ('motion'), (new or {}).get ('motion')]
+        base_motion, new_motion = row['motion']
+        # the second frame moved noticeably less than with the base build
+        # runs made before the motion check have no "motion" key at all, None means the later render failed
+        less_motion = ('motion' in (new or {}) and base_motion is not None and base_motion * 100 >= args.motion_min
+                       and (new_motion or 0) < base_motion * 0.5)
+
+        if row['new'] == 'missing':
+            row['verdict'] = 'not run'
+        elif row['base'] == 'missing':
+            row['verdict'] = 'new wallpaper'
+        elif row['new'] != 'ok' and row['base'] == 'ok':
+            row['verdict'] = 'broken'
+        elif row['new'] == 'ok' and row['base'] not in ('ok', 'missing'):
+            row['verdict'] = 'fixed'
+        elif row['added_errors']:
+            row['verdict'] = 'new errors'
+        elif less_motion:
+            row['verdict'] = 'less motion'
+        elif row['changed'] is not None and row['changed'] * 100 > (args.video_tolerance if row['video'] else args.tolerance):
+            row['verdict'] = 'changed'
+        elif row['fixed_errors']:
+            row['verdict'] = 'fewer errors'
+
+        if row['verdict'] not in ('same', 'not run'):
+            for side, shot in (('base', base_shot), ('new', new_shot),
+                               ('base_motion', args.base / wallpaper_id / 'shot_motion.png'),
+                               ('new_motion', args.new / wallpaper_id / 'shot_motion.png')):
+                if shot.exists ():
+                    thumbnail (shot, report / 'img' / f'{wallpaper_id}_{side}.jpg')
+
+        rows.append (row)
+
+    order = {'broken': 0, 'new errors': 1, 'less motion': 2, 'changed': 3, 'fixed': 4, 'fewer errors': 5,
+             'new wallpaper': 6, 'same': 7, 'not run': 8}
+    rows.sort (key = lambda r: (order[r['verdict']], -(r['changed'] or 0)))
+
+    (report / 'summary.json').write_text (json.dumps (rows, ensure_ascii = False, indent = 1))
+    write_html (report, args, rows)
+
+    counts = {}
+    for row in rows:
+        counts[row['verdict']] = counts.get (row['verdict'], 0) + 1
+
+    print (', '.join (f'{count} {verdict}' for verdict, count in sorted (counts.items (), key = lambda c: order[c[0]])))
+
+    for row in rows:
+        if row['verdict'] not in ('same', 'not run'):
+            changed = f' {row["changed"] * 100:.2f}% of pixels' if row['changed'] else ''
+            print (f'  {row["verdict"]:12} {row["id"]} {row["title"][:50]}{changed}')
+
+    print (f'report: {report / "index.html"}')
+
+    regressed = any (r['verdict'] in ('broken', 'new errors', 'less motion', 'changed') for r in rows)
+    sys.exit (1 if regressed else 0)
+
+
+def write_html (report, args, rows):
+    def esc (value):
+        return html.escape (str (value))
+
+    def run_info (folder):
+        try:
+            return json.loads ((folder / 'run.json').read_text ())
+        except (OSError, ValueError):
+            return {}
+
+    base_info = run_info (args.base)
+    new_info = run_info (args.new)
+    parts = [f'''<!doctype html><html><head><meta charset="utf-8"><title>Wallpaper regressions</title>
+<style>
+body {{ font: 14px system-ui, sans-serif; margin: 24px; background: #16161a; color: #ddd; }}
+a {{ color: #8ab4f8; }} h2 {{ margin-top: 40px; }}
+table {{ border-collapse: collapse; }} td, th {{ padding: 4px 10px; border-bottom: 1px solid #333; text-align: left; }}
+.broken, .new-errors {{ color: #ff6b6b; }} .changed {{ color: #ffb86b; }} .fixed, .fewer-errors {{ color: #7ee787; }}
+.pair {{ display: flex; gap: 8px; flex-wrap: wrap; }} .pair figure {{ margin: 0; }} .pair img {{ max-width: 420px; display: block; }}
+figcaption {{ color: #999; font-size: 12px; }} pre {{ background: #222; padding: 8px; overflow-x: auto; }}
+</style></head><body>
+<h1>Wallpaper regressions</h1>
+<p>base: {esc (args.base)} ({esc (base_info.get ('binary', '?'))}, {esc (base_info.get ('started', ''))})<br>
+new: {esc (args.new)} ({esc (new_info.get ('binary', '?'))}, {esc (new_info.get ('started', ''))})<br>
+frame {esc (new_info.get ('frame', '?'))} at {esc (new_info.get ('step', '?'))}s per frame, pixel threshold {args.threshold},
+changed above {args.tolerance}% of pixels ({args.video_tolerance}% with video textures)</p>
+<p>Heatmaps: red is changed, blue is ignored because it already differs between repeated renders of one build
+(<code>run --repeat 2</code>), grey is unchanged.</p>
+<table><tr><th>verdict</th><th>id</th><th>title</th><th>base</th><th>new</th><th>pixels changed</th><th>ignored</th><th>motion base / new</th></tr>''']
+
+    for row in rows:
+        css = row['verdict'].replace (' ', '-')
+        changed = f'{row["changed"] * 100:.2f}%' if row['changed'] is not None else '-'
+        ignored = f'{row["ignored"] * 100:.1f}%' if row['ignored'] else ''
+        title = esc (row['title']) + (' <small>(video texture)</small>' if row['video'] else '')
+        detailed = row['verdict'] not in ('same', 'not run')
+        link = f'<a href="#w{esc (row["id"])}">{esc (row["id"])}</a>' if detailed else esc (row['id'])
+        motion = ' / '.join ('-' if m is None else f'{m * 100:.2f}%' for m in row['motion']) if row['animated'] else ''
+        if row['animated']:
+            title += f' <small>({", ".join (row["animated"])})</small>'
+
+        parts.append (f'<tr><td class="{css}">{esc (row["verdict"])}</td><td>{link}</td><td>{title}</td>'
+                      f'<td>{esc (row["base"])}</td><td>{esc (row["new"])}</td><td>{changed}</td><td>{ignored}</td><td>{motion}</td></tr>')
+
+    parts.append ('</table>')
+
+    for row in rows:
+        if row['verdict'] in ('same', 'not run'):
+            continue
+
+        parts.append (f'<h2 id="w{esc (row["id"])}" class="{row["verdict"].replace (" ", "-")}">'
+                      f'{esc (row["id"])} - {esc (row["title"])} ({esc (row["verdict"])})</h2><div class="pair">')
+
+        for name, caption in (('base', 'base'), ('new', 'new'), ('diff', 'changed pixels'),
+                              ('base_motion', 'base, a few frames later'), ('new_motion', 'new, a few frames later')):
+            image = report / 'img' / f'{row["id"]}_{name}.jpg'
+            if image.exists ():
+                parts.append (f'<figure><img src="img/{image.name}"><figcaption>{caption}</figcaption></figure>')
+
+        parts.append ('</div>')
+
+        if row['added_errors']:
+            parts.append ('<p>new in the log:</p><pre>' + esc ('\n'.join (row['added_errors'])) + '</pre>')
+        if row['fixed_errors']:
+            parts.append ('<p>gone from the log:</p><pre>' + esc ('\n'.join (row['fixed_errors'])) + '</pre>')
+
+    parts.append ('</body></html>')
+    (report / 'index.html').write_text ('\n'.join (parts))
+
+
+def main ():
+    parser = argparse.ArgumentParser (description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter)
+    commands = parser.add_subparsers (dest = 'command', required = True)
+
+    run = commands.add_parser ('run', help = 'render every wallpaper with one build')
+    run.add_argument ('binary', type = Path, help = 'the linux-wallpaperengine executable to test')
+    run.add_argument ('out', type = Path, help = 'folder for the screenshots and logs')
+    run.add_argument ('--workshop', type = Path, default = env_path ('LWE_WORKSHOP_DIR', DEFAULT_WORKSHOP))
+    run.add_argument ('--assets', type = Path, default = env_path ('LWE_ASSETS_DIR', DEFAULT_ASSETS))
+    run.add_argument ('--ids', nargs = '*', help = 'only these workshop ids')
+    run.add_argument ('--variants', type = Path, default = HERE / 'variants.json',
+                      help = 'extra renders with user properties set (default: variants.json next to this script)')
+    run.add_argument ('--types', default = 'scene', help = 'project types to include, comma separated (default: scene)')
+    run.add_argument ('--jobs', type = int, default = max (1, min (8, (os.cpu_count () or 2) // 4)))
+    run.add_argument ('--frame', type = int, default = 30, help = 'frame to screenshot (default: 30)')
+    run.add_argument ('--step', type = float, default = 0.1, help = 'animation seconds per frame (default: 0.1)')
+    run.add_argument ('--clock', type = int, default = 1767268800, help = 'pinned wall clock, unix seconds')
+    run.add_argument ('--width', type = int, default = 1280)
+    run.add_argument ('--height', type = int, default = 720)
+    run.add_argument ('--repeat', type = int, default = 1,
+                      help = 'renders per wallpaper; use 2+ for the base run so compare can ignore noise')
+    run.add_argument ('--motion-gap', type = int, default = 5,
+                      help = 'frames between the two renders of wallpapers with gif/video textures (0 disables)')
+    run.add_argument ('--timeout', type = float, default = 300, help = 'seconds per wallpaper')
+    run.set_defaults (func = cmd_run)
+
+    compare = commands.add_parser ('compare', help = 'compare two runs and write a report')
+    compare.add_argument ('base', type = Path)
+    compare.add_argument ('new', type = Path)
+    compare.add_argument ('--report', type = Path, default = Path ('regression-report'))
+    compare.add_argument ('--threshold', type = int, default = 16, help = 'per-channel difference that counts as changed')
+    compare.add_argument ('--video-tolerance', type = float, default = 5,
+                          help = 'percent of changed pixels allowed with video textures, the motion check covers those')
+    compare.add_argument ('--motion-min', type = float, default = 0.05,
+                          help = 'percent of pixels that must move in the base run for the motion check to apply')
+    compare.add_argument ('--tolerance', type = float, default = 0.1, help = 'percent of changed pixels allowed')
+    compare.set_defaults (func = cmd_compare)
+
+    args = parser.parse_args ()
+    args.func (args)
+
+
+if __name__ == '__main__':
+    main ()

+ 6 - 0
tools/regression/variants.json

@@ -0,0 +1,6 @@
+{
+  "_comment": "Extra renders with user properties set, for things wallpapers hide by default. workshop id -> variant name -> --set-property values",
+  "3164292430": {
+    "chains": ["chainstop=1", "chainsbottom=1"]
+  }
+}