Explorar el Código

upgraded tests, fixed scriptengines weather widget, calendar, clock, media player, changed default scaling to cover (just as original we), puppet animation picks the correct clip now

UwU hace 1 hora
padre
commit
6795f187d9
Se han modificado 64 ficheros con 3903 adiciones y 534 borrados
  1. 3 0
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  2. 15 1
      src/WallpaperEngine/Data/Model/Object.h
  3. 17 0
      src/WallpaperEngine/Data/Model/Wallpaper.h
  4. 26 3
      src/WallpaperEngine/Data/Parsers/MaterialParser.cpp
  5. 39 3
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  6. 2 1
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  7. 147 0
      src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
  8. 11 4
      src/WallpaperEngine/Media/DBusMediaSource.cpp
  9. 15 3
      src/WallpaperEngine/Render/Camera.cpp
  10. 2 1
      src/WallpaperEngine/Render/Camera.h
  11. 19 3
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp
  12. 5 0
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h
  13. 46 22
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  14. 3 0
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
  15. 336 110
      src/WallpaperEngine/Render/Objects/CImage.cpp
  16. 26 7
      src/WallpaperEngine/Render/Objects/CImage.h
  17. 77 4
      src/WallpaperEngine/Render/Objects/CMesh.cpp
  18. 10 0
      src/WallpaperEngine/Render/Objects/CMesh.h
  19. 36 19
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  20. 2 0
      src/WallpaperEngine/Render/Objects/CParticle.h
  21. 75 84
      src/WallpaperEngine/Render/Objects/CText.cpp
  22. 9 0
      src/WallpaperEngine/Render/Objects/CText.h
  23. 151 50
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  24. 18 0
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  25. 236 18
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  26. 8 2
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  27. 353 49
      src/WallpaperEngine/Render/Volumetrics.cpp
  28. 26 12
      src/WallpaperEngine/Render/Volumetrics.h
  29. 19 22
      src/WallpaperEngine/Render/WallpaperState.cpp
  30. 568 55
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  31. 77 0
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  32. 31 1
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  33. 8 0
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h
  34. 16 2
      src/WallpaperEngine/Scripting/ConsoleObject.cpp
  35. 69 7
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  36. 12 3
      src/WallpaperEngine/Scripting/ScriptEngine.h
  37. 146 44
      src/WallpaperEngine/Scripting/ScriptPropertiesObject.cpp
  38. 14 1
      src/WallpaperEngine/Scripting/ScriptPropertiesObject.h
  39. BIN
      tools/__pycache__/unpack_pkg.cpython-313.pyc
  40. 20 3
      tools/headless_render.sh
  41. 16 0
      tools/regression/regress.py
  42. 79 0
      tools/wecompare/README.md
  43. BIN
      tools/wecompare/__pycache__/scan.cpython-313.pyc
  44. 84 0
      tools/wecompare/compare.py
  45. 17 0
      tools/wecompare/crop.py
  46. 11 0
      tools/wecompare/frames.py
  47. 20 0
      tools/wecompare/render_ours.sh
  48. 171 0
      tools/wecompare/render_we.py
  49. 335 0
      tools/wecompare/scan.py
  50. 113 0
      tools/wecompare/wine/qrenderdoc_analytics.json
  51. 29 0
      tools/wecompare/wine/rd_trigger.py
  52. 22 0
      tools/wecompare/wine/rdscripts/rd_actions.py
  53. 23 0
      tools/wecompare/wine/rdscripts/rd_cbraw.py
  54. 20 0
      tools/wecompare/wine/rdscripts/rd_draws.py
  55. 30 0
      tools/wecompare/wine/rdscripts/rd_frag.py
  56. 14 0
      tools/wecompare/wine/rdscripts/rd_samp.py
  57. 10 0
      tools/wecompare/wine/rdscripts/rd_save.py
  58. 13 0
      tools/wecompare/wine/rdscripts/rd_vtx.py
  59. 48 0
      tools/wecompare/wine/renderdoc_capture.json
  60. 18 0
      tools/wecompare/wine/repack_pkg.py
  61. 25 0
      tools/wecompare/wine/we_capture.sh
  62. 47 0
      tools/wecompare/wine/we_cursor.sh
  63. 19 0
      tools/wecompare/wine/we_display.sh
  64. 46 0
      tools/wecompare/wine/we_full.sh

+ 3 - 0
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -2029,6 +2029,9 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 
 	glFinish ();
 
+	// errors left over from rendering (e.g. a uniform type mismatch) would otherwise fail the readback below
+	while (glGetError () != GL_NO_ERROR) {}
+
 	const int readWidth = wallpaper->getCanvasWidth ();
 	const int readHeight = wallpaper->getCanvasHeight ();
 	const auto bufferSize = readWidth * readHeight * 3;

+ 15 - 1
src/WallpaperEngine/Data/Model/Object.h

@@ -74,12 +74,14 @@ struct ImageEffect {
 
 struct ImageAnimationLayer {
     int id;
-    /** Matches the name of a baked animation clip stored in the puppet .mdl's MDLA section */
+    /** Editor label only, the clip is picked by "animation" (the clip id in the puppet .mdl) */
     std::string name;
     UserSettingUniquePtr rate;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr blend;
     UserSettingUniquePtr animation;
+    /** adds its difference from the rest pose instead of blending towards the clip's pose */
+    bool additive = false;
 };
 
 enum ImageAlignment {
@@ -97,6 +99,8 @@ struct ImageData {
     UserSettingUniquePtr alpha;
     UserSettingUniquePtr color;
     uint32_t alignment;
+    /** the alignment name, scripts may change it (ILayer.alignment, 2.8.42 sub_140212690) */
+    UserSettingUniquePtr alignmentName;
     /** In pixels */
     glm::vec2 size;
     UserSettingUniquePtr parallaxDepth;
@@ -1015,6 +1019,16 @@ struct LightData {
     bool castShadow = false;
     UserSettingUniquePtr density;
     UserSettingUniquePtr volumetricsExponent;
+    /** Spot lights: half angles of the cone in degrees, full brightness inside the inner one */
+    UserSettingUniquePtr innerCone;
+    UserSettingUniquePtr outerCone;
+    /** LightingV1 falloff exponent of the lit surfaces */
+    UserSettingUniquePtr exponent;
+    /** Tube lights: the far end of the tube in the light's own space */
+    UserSettingUniquePtr controlPoint;
+    /** Spot lights projecting a texture ("cookie", WE falls back to cookie/flashlight1) */
+    bool useCookie = false;
+    std::string cookie;
 };
 
 class Light : public Object, public LightData {

+ 17 - 0
src/WallpaperEngine/Data/Model/Wallpaper.h

@@ -73,6 +73,21 @@ struct SceneData {
 	UserSettingUniquePtr heightStartDensity;
 	UserSettingUniquePtr heightEndDensity;
     } fog;
+    /**
+     * "lightconfig": how many LightingV1 lights of each kind lit materials take (scene constructor sub_140186C90 packs
+     * them into the renderer's +4636 bitfield, 4 bits per light type, 2 per shadow/cookie count)
+     */
+    struct {
+	int point = 0;
+	int spot = 0;
+	int tube = 0;
+	int directional = 0;
+	int spotCookie = 0;
+	int spotShadow = 0;
+	int spotShadowCookie = 0;
+	int directionalShadow = 0;
+	int pointShadow = 0;
+    } lightConfig;
     struct Camera {
 	UserSettingUniquePtr fade;
 	/** Whether the software's preview UI is allowed to show this background */
@@ -127,6 +142,8 @@ struct SceneData {
 	    UserSettingUniquePtr zoom;
 	} projection;
     } camera;
+    /** 3D scenes: particles, text, lights and blended images draw after the rest, back to front */
+    UserSettingUniquePtr transparentSorting;
 
     ObjectList objects;
 };

+ 26 - 3
src/WallpaperEngine/Data/Parsers/MaterialParser.cpp

@@ -10,15 +10,37 @@ using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 
 MaterialUniquePtr MaterialParser::load (const Project& project, const std::string& filename) {
-    const auto materialJson = JSON::parseAsset (project.assetLocator->readString (filename));
+    JSON materialJson;
+
+    // WE (sub_1401515B0) logs a material it can't open or parse and builds it from an empty object instead, which
+    // ends up as one pass with its "error" shader, a plain copy of g_Texture0: the effect using it changes nothing
+    try {
+	materialJson = JSON::parseAsset (project.assetLocator->readString (filename));
+    } catch (const std::exception& e) {
+	sLog.error ("Material ", filename, " error: ", e.what ());
+	materialJson = JSON::object ();
+    }
 
     return parse (materialJson, filename, project);
 }
 
 MaterialUniquePtr MaterialParser::parse (const JSON& it, const std::string& filename, const Project& project) {
+    const auto passes = it.find ("passes");
+
+    // without a non-empty "passes" array WE reads the material's root as its pass
+    if (passes == it.end () || !passes->is_array () || passes->empty ()) {
+	std::vector<MaterialPassUniquePtr> single;
+	single.push_back (parsePass (it, project));
+
+	return std::make_unique<Material> (Material {
+	    .filename = filename,
+	    .passes = std::move (single),
+	});
+    }
+
     return std::make_unique<Material> (Material {
 	.filename = filename,
-	.passes = parsePasses (it.require ("passes", "Material must have passes to render"), project),
+	.passes = parsePasses (*passes, project),
     });
 }
 
@@ -49,7 +71,8 @@ MaterialPassUniquePtr MaterialParser::parsePass (const JSON& it, const Project&
 	.cullmode = it.contains ("cullmode") ? parseCullMode (it["cullmode"]) : CullingMode_Unknown,
 	.depthtest = it.contains ("depthtest") ? parseDepthtestMode (it["depthtest"]) : DepthtestMode_Unknown,
 	.depthwrite = it.contains ("depthwrite") ? parseDepthwriteMode (it["depthwrite"]) : DepthwriteMode_Unknown,
-	.shader = it.require<std::string> ("shader", "Material pass must have a shader"),
+	// no shader falls back to WE's "error" shader too (sub_140154480)
+	.shader = it.optional<std::string> ("shader", "error"),
 	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : TextureMap {},
 	.usertextures = usertextures.has_value () ? TextureParser::parseTextureMap (*usertextures) : TextureMap {},
 	.combos = combos.has_value () ? parseCombos (*combos) : ComboMap {},

+ 39 - 3
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -8,6 +8,7 @@
 #include "TextureParser.h"
 #include "UserSettingParser.h"
 #include "WallpaperEngine/Data/Builders/ColorBuilder.h"
+#include "WallpaperEngine/Data/Builders/UserSettingBuilder.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Logging/Log.h"
@@ -72,6 +73,17 @@ glm::vec3 parseRemapRange (const JSON& it, const std::string& key, float fallbac
     return parseFloats (value->get<std::string> ());
 }
 
+/** rotationrandom min/max: sub_1401C5490 puts a plain number into z only, x and y stay 0 */
+UserSettingUniquePtr
+rotationBound (const JSON& it, const std::string& key, const Properties& properties, glm::vec3 fallback) {
+    if (const auto value = it.optional (key); value.has_value () && value->is_number ()) {
+	return WallpaperEngine::Data::Builders::UserSettingBuilder::fromValue<glm::vec3> (
+	    glm::vec3 (0.0f, 0.0f, value->get<float> ())
+	);
+    }
+    return it.user (key, properties, fallback);
+}
+
 template <typename T, size_t N>
 T parseName (const JSON& it, const std::string& key, const char* fallback, const char* const (&names)[N], T unknown) {
     const std::string name = it.optional<std::string> (key, fallback);
@@ -482,6 +494,12 @@ LightUniquePtr ObjectParser::parseLight (const JSON& it, const Project& project,
 	    .castShadow = it.optional ("castshadow", false),
 	    .density = it.user ("density", project.properties, 2.0f),
 	    .volumetricsExponent = it.user ("volumetricsexponent", project.properties, 1.0f),
+	    .innerCone = it.user ("innercone", project.properties, 20.0f),
+	    .outerCone = it.user ("outercone", project.properties, 30.0f),
+	    .exponent = it.user ("exponent", project.properties, 2.0f),
+	    .controlPoint = it.user ("controlpoint", project.properties, glm::vec3 (2.0f, 0.0f, 0.0f)),
+	    .useCookie = it.optional ("usecookie", false),
+	    .cookie = it.optional ("cookie", std::string ()),
 	}
     );
 }
@@ -561,11 +579,25 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
     const auto& effects = it.optional ("effects");
     const auto& properties = project.properties;
 
+    // WE's text property is a string whatever it holds, "2027" must not turn into a number (3577513994's year
+    // and day texts, which scripts then set to numbers)
+    auto text = it.user ("text", project.properties);
+    if (text->value->getType () == DynamicValue::Float || text->value->getType () == DynamicValue::Int) {
+	auto raw = it.optional ("text");
+	if (raw.has_value () && raw->is_object ()) {
+	    const auto inner = raw->find ("value");
+	    raw = inner != raw->end () ? std::optional<nlohmann::json> (*inner) : std::nullopt;
+	}
+	if (raw.has_value () && raw->is_string ()) {
+	    text->value->update (raw->get<std::string> (), DynamicValue::UpdateSource::Initialization);
+	}
+    }
+
     // defaults are the text object constructor's in wallpaper64.exe 2.8.42 (sub_140256AE0)
     return std::make_unique<Text> (
 	std::move (base),
 	TextData {
-	    .text = it.user ("text", project.properties),
+	    .text = std::move (text),
 	    .font = it.optional ("font", std::string ("systemfont_arial")),
 	    .pointSize = it.user ("pointsize", project.properties, 32.0f),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
@@ -623,6 +655,9 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	    .color = it.color ("color", properties, Builders::ColorBuilder::White),
 	    .alignment
 	    = parseAlignment (it.optional ("horizontalalign", it.optional ("alignment", std::string ("center")))),
+	    .alignmentName = Builders::UserSettingBuilder::fromValue<std::string> (
+		it.optional ("horizontalalign", it.optional ("alignment", std::string ("center")))
+	    ),
 	    .size = it.user ("size", properties, glm::vec2 (0.0f))->value->getVec2 (),
 	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (1.0f)),
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
@@ -756,6 +791,7 @@ ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it,
 	.visible = it.user ("visible", properties, false),
 	.blend = it.user ("blend", properties, 1.0f),
 	.animation = it.user ("animation", properties, 0),
+	.additive = it.optional ("additive", false),
     });
 }
 
@@ -1137,8 +1173,8 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
 	);
     } else if (name == "rotationrandom") {
 	return std::make_unique<RotationRandomInitializer> (
-	    it.user ("min", properties, glm::vec3 (0.0f)),
-	    it.user ("max", properties, glm::vec3 (0.0f, 0.0f, glm::two_pi<float> ()))
+	    rotationBound (it, "min", properties, glm::vec3 (0.0f)),
+	    rotationBound (it, "max", properties, glm::vec3 (0.0f, 0.0f, glm::two_pi<float> ()))
 	);
     } else if (name == "angularvelocityrandom") {
 	return std::make_unique<AngularVelocityRandomInitializer> (

+ 2 - 1
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -15,13 +15,14 @@ using namespace WallpaperEngine::Data::Model;
 
 class ObjectParser {
 public:
+    /** ImageAlignment flags from an alignment name, images re-read it when a script changes it */
+    static uint32_t parseAlignment (const std::string& alignment);
     static ObjectUniquePtr parse (const JSON& it, const Project& project);
 
 private:
     static std::vector<int> parseDependencies (const JSON& it);
     static SoundUniquePtr parseSound (const JSON& it, const Project& project, ObjectData base);
     static SoundPlaybackMode parsePlaybackMode (const std::string& mode);
-    static uint32_t parseAlignment (const std::string& alignment);
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base, int depth = 0);

+ 147 - 0
src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp

@@ -1,11 +1,14 @@
 #include "WallpaperParser.h"
 
 #include "ObjectParser.h"
+#include "WallpaperEngine/Data/Model/Model.h"
+#include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/FileSystem/Container.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <algorithm>
 #include <sstream>
 
 using namespace WallpaperEngine::Data::Parsers;
@@ -33,6 +36,121 @@ float numberOr (const JSON& data, const char* key, const float fallback) {
     return it != data.end () && it->is_number () ? it->get<float> () : fallback;
 }
 
+bool isPassthroughImage (const Object& object) {
+    return object.is<Image> () && object.as<Image> ()->model != nullptr && object.as<Image> ()->model->passthrough;
+}
+
+// WE links an object to its parent while creating the objects in array order and resolves parents further down the
+// array in a second pass (sub_140186C90), each time dropping the link when passthrough layers would nest deeper than 3:
+// the passthrough ancestors plus the longest passthrough chain from the object down (sub_1401DE470, sub_1401DE750)
+void dropDeepPassthroughParents (ObjectList& objects) {
+    const int count = static_cast<int> (objects.size ());
+    std::vector<int> linked (count, -1);
+    std::vector<std::vector<int>> children (count);
+
+    const auto ancestorsDepth = [&] (int index) {
+	int depth = 0;
+
+	for (int steps = 0; index != -1 && steps < count; steps++, index = linked[index]) {
+	    depth += isPassthroughImage (*objects[index]) ? 1 : 0;
+	}
+
+	return depth;
+    };
+    const auto chainDepth = [&] (const auto& self, int index, int level) -> int {
+	const int own = isPassthroughImage (*objects[index]) ? 1 : 0;
+	int depth = own;
+
+	if (level < count) {
+	    for (const int child : children[index]) {
+		depth = std::max (depth, self (self, child, level + 1) + own);
+	    }
+	}
+
+	return depth;
+    };
+    const auto link = [&] (int index, int parent) {
+	if (linked[index] != -1) {
+	    std::erase (children[linked[index]], index);
+	}
+
+	linked[index] = parent;
+	children[parent].push_back (index);
+    };
+    const auto tooDeep = [&] (int index, int parent) {
+	return ancestorsDepth (parent) + chainDepth (chainDepth, index, 0) > 3;
+    };
+    const auto drop = [&] (int index) {
+	sLog.out (
+	    "Object ", objects[index]->id, " loses its parent ", objects[index]->parent.value (),
+	    ": passthrough layers nest deeper than 3"
+	);
+	objects[index]->parent = std::nullopt;
+    };
+
+    for (int index = 0; index < count; index++) {
+	if (!objects[index]->parent.has_value ()) {
+	    continue;
+	}
+
+	int parent = -1;
+
+	for (int other = 0; other <= index; other++) {
+	    if (objects[other]->id == objects[index]->parent.value ()) {
+		parent = other;
+		break;
+	    }
+	}
+
+	if (parent == index || (parent != -1 && tooDeep (index, parent))) {
+	    drop (index);
+	} else if (parent != -1) {
+	    link (index, parent);
+	}
+    }
+
+    for (int index = 0; index < count; index++) {
+	if (!objects[index]->parent.has_value ()) {
+	    continue;
+	}
+
+	int parent = -1;
+
+	for (int other = 0; other < count; other++) {
+	    if (other != index && objects[other]->id == objects[index]->parent.value ()) {
+		parent = other;
+		break;
+	    }
+	}
+
+	if (parent == -1 || linked[index] == parent) {
+	    continue;
+	}
+
+	// a parent below the object is a cycle, left to the scene's own guards
+	bool below = false;
+
+	for (int current = parent, steps = 0; current != -1 && steps < count; current = linked[current], steps++) {
+	    below = below || current == index;
+	}
+
+	if (below) {
+	    continue;
+	}
+
+	if (tooDeep (index, parent)) {
+	    if (linked[index] != -1) {
+		std::erase (children[linked[index]], index);
+		linked[index] = -1;
+	    }
+
+	    drop (index);
+	} else {
+	    link (index, parent);
+	}
+    }
+}
+
 bool isDisabled (const JSON& data) {
     const auto it = data.find ("disabled");
 
@@ -106,6 +224,31 @@ void parseCameraPathFile (const std::string& filename, const Project& project, s
     }
 }
 
+decltype (SceneData::lightConfig) parseLightConfig (const JSON& general) {
+    decltype (SceneData::lightConfig) config;
+    const auto it = general.find ("lightconfig");
+
+    if (it == general.end () || !it->is_object ()) {
+	return config;
+    }
+
+    const auto count = [&it] (const char* key, const int mask) {
+	const auto value = it->find (key);
+	return value != it->end () && value->is_number () ? value->get<int> () & mask : 0;
+    };
+
+    config.point = count ("point", 0xF);
+    config.spot = count ("spot", 0xF);
+    config.tube = count ("tube", 0xF);
+    config.directional = count ("directional", 0xF);
+    config.spotCookie = count ("spotcookie", 3);
+    config.spotShadow = count ("spotshadow", 3);
+    config.spotShadowCookie = count ("spotshadowcookie", 3);
+    config.directionalShadow = count ("directionalshadow", 3);
+    config.pointShadow = count ("pointshadow", 3);
+    return config;
+}
+
 std::vector<CameraPath> parseCameraPaths (const JSON& camera, const Project& project) {
     std::vector<CameraPath> paths;
     const auto list = camera.find ("paths");
@@ -175,6 +318,7 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                 .heightStartDensity = general.user ("fogheightstartdensity", properties, 0.0f),
                 .heightEndDensity = general.user ("fogheightenddensity", properties, 1.0f),
             },
+            .lightConfig = parseLightConfig (general),
             .camera = {
                 .fade = general.user ("camerafade", properties, false),
                 .preview = general.optional ("camerapreview", false),
@@ -220,6 +364,7 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                     .zoom = general.user ("zoom", properties, 1.0f),
                 }
             },
+            .transparentSorting = general.user ("transparentsorting", properties, false),
             .objects = parseObjects (objects, project),
         }
     );
@@ -243,5 +388,7 @@ ObjectList WallpaperParser::parseObjects (const JSON& objects, const Project& pr
 	result.emplace_back (ObjectParser::parse (cur, project));
     }
 
+    dropDeepPassthroughParents (result);
+
     return result;
 }

+ 11 - 4
src/WallpaperEngine/Media/DBusMediaSource.cpp

@@ -73,9 +73,6 @@ DBusMediaSource::DBusMediaSource (std::chrono::milliseconds updateInterval) : Me
     );
 
     dbus_connection_flush (this->m_connection);
-
-    this->detectPlayer ();
-    this->initialStatusFetch ();
 }
 
 DBusMediaSource::~DBusMediaSource () {
@@ -243,7 +240,16 @@ DBusMessage* DBusMediaSource::dbusMessage (
     DBusMessage* reply = dbus_connection_send_with_reply_and_block (m_connection, msg, -1, &err);
 
     if (reply == nullptr) {
-	sLog.error ("DBus error: ", err.message, " (", err.name, ")");
+	// the player quit (closed tab, stopped player) or doesn't export its object yet, not an error
+	if (dbus_error_has_name (&err, DBUS_ERROR_SERVICE_UNKNOWN)
+	    || dbus_error_has_name (&err, DBUS_ERROR_NAME_HAS_NO_OWNER)
+	    || dbus_error_has_name (&err, DBUS_ERROR_UNKNOWN_OBJECT)) {
+	    sLog.debug ("DBus player ", bus_name, " went away: ", err.message);
+	} else {
+	    sLog.error ("DBus error: ", err.message, " (", err.name, ")");
+	}
+
+	dbus_error_free (&err);
 	return nullptr;
     }
 
@@ -341,6 +347,7 @@ void DBusMediaSource::performUpdate () {
     // no player detected yet (or the previous one went away) - try to find one
     if (!this->m_currentPlayer.has_value ()) {
 	this->detectPlayer ();
+	this->initialStatusFetch ();
 	return;
     }
 

+ 15 - 3
src/WallpaperEngine/Render/Camera.cpp

@@ -120,12 +120,24 @@ void Camera::setZoom (const float zoom) {
 }
 
 void Camera::setPerspectiveView (const glm::mat4& view, const float fov) {
+    const float radians = glm::radians (glm::clamp (fov, 0.1f, 179.9f));
+
     this->m_worldView = view;
     this->m_eye = glm::vec3 (glm::inverse (view)[3]);
     this->m_view = view;
-    this->m_perspective = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
-	* glm::perspective (glm::radians (glm::clamp (fov, 0.1f, 179.9f)), this->m_width / this->m_height,
-			    this->getNearZ (), this->getFarZ ());
+    this->m_perspective
+	= kFlipY * glm::perspective (radians, this->m_width / this->m_height, this->getNearZ (), this->getFarZ ());
+
+    // "perspective" layers in 3D scenes: sub_140183A70 sets the renderer fov to 2 atan (tan (fov / 2) / 2000), so
+    // sub_1401E5B60 always backs off to 2000, keeps the view's rotation and x/y (the visible region offsets are 0 in
+    // 3D scenes) and projects with that narrow fov, near 5, far 15000
+    glm::mat4 layerView = view;
+    layerView[3][2] = -2000.0f;
+    this->m_perspectiveLayer = kFlipY
+	* glm::perspective (
+	    2.0f * std::atan (std::tan (radians / 2.0f) / 2000.0f), this->m_width / this->m_height, 5.0f, 15000.0f
+	)
+	* layerView;
 }
 
 void Camera::updatePerspectiveLayers (const glm::vec4& uvs, const float viewportAspect) {

+ 2 - 1
src/WallpaperEngine/Render/Camera.h

@@ -70,7 +70,8 @@ public:
      * on screen: uvs are the output's texture window over the scene buffer (ustart, uend, vstart, vend)
      */
     void updatePerspectiveLayers (const glm::vec4& uvs, float viewportAspect);
-    /** View projection of "perspective" layers, same space as getProjection () * getLookAt () */
+    /** View projection of "perspective" layers, same space as getProjection () * getLookAt () in 2D scenes, WE's world
+     *  in 3D ones */
     [[nodiscard]] const glm::mat4& getPerspectiveLayerViewProjection () const;
 
 private:

+ 19 - 3
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -102,9 +102,7 @@ static void surfaceFrameCallback (void* data, struct wl_callback* cb, uint32_t t
     wl_callback_destroy (cb);
 
     viewport->frameCallback = nullptr;
-    viewport->rendering = true;
-    viewport->getDriver ()->getApp ().update (viewport);
-    viewport->rendering = false;
+    viewport->renderFrame ();
 }
 
 constexpr struct wl_callback_listener frameListener = { .done = surfaceFrameCallback };
@@ -388,6 +386,11 @@ void WaylandOutputViewport::setupLS () {
 	sLog.exception ("Failed to make egl current");
     }
 
+    // frames are paced by our own frame callbacks; with the default interval of 1 Mesa blocks inside
+    // eglSwapBuffers until the compositor shows the previous frame, which never happens while the
+    // surface isn't presented and stalls the whole main loop
+    eglSwapInterval (m_driver->getEGLContext ()->display, 0);
+
     this->m_driver->getOutput ().reset ();
 }
 
@@ -403,10 +406,23 @@ void WaylandOutputViewport::makeCurrent () {
     }
 }
 
+void WaylandOutputViewport::renderFrame () {
+    this->rendering = true;
+    this->m_driver->getApp ().update (this);
+    this->rendering = false;
+}
+
 void WaylandOutputViewport::swapOutput () {
     this->callbackInitialized = true;
+    this->lastSwap = std::chrono::steady_clock::now ();
 
     this->makeCurrent ();
+
+    // a forced frame replaces the pending callback, two live callbacks would double the frame rate
+    if (frameCallback) {
+	wl_callback_destroy (frameCallback);
+    }
+
     frameCallback = wl_surface_frame (surface);
     wl_callback_add_listener (frameCallback, &frameListener, this);
     eglSwapBuffers (m_driver->getEGLContext ()->display, this->eglSurface);

+ 5 - 0
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -9,6 +9,8 @@
 #include <wayland-cursor.h>
 #include <wayland-egl.h>
 
+#include <chrono>
+
 #include "../WaylandOpenGLDriver.h"
 #include "OutputViewport.h"
 #include <WallpaperEngine/Input/MouseInput.h>
@@ -59,6 +61,8 @@ namespace Output {
 	wl_surface* cursorSurface = nullptr;
 	wl_cursor_theme* cursorTheme = nullptr;
 	bool callbackInitialized = false;
+	/** Last time a frame was handed to the compositor, drives the missed frame callback watchdog */
+	std::chrono::steady_clock::time_point lastSwap = {};
 	bool hasXdgLogicalPosition = false;
 	zxdg_output_v1* xdgOutput = nullptr;
 
@@ -85,6 +89,7 @@ namespace Output {
 	} pendingDescription;
 
 	void makeCurrent () override;
+	void renderFrame ();
 	void swapOutput () override;
 	void resize ();
 

+ 46 - 22
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -29,6 +29,7 @@ extern "C" {
 using namespace WallpaperEngine::Render::Drivers;
 
 constexpr int EVENT_WAIT_TIMEOUT_MS = 100;
+constexpr auto MISSED_FRAME_TIMEOUT = std::chrono::seconds (1);
 
 static void handlePointerEnter (
     void* data, struct wl_pointer* wl_pointer, uint32_t serial, struct wl_surface* surface, wl_fixed_t surface_x,
@@ -350,6 +351,11 @@ void WaylandOpenGLDriver::finishEGL () const {
 void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport) {
     sLog.error ("Compositor closed our LS, freeing data...");
 
+    if (viewport->frameCallback) {
+	wl_callback_destroy (viewport->frameCallback);
+	viewport->frameCallback = nullptr;
+    }
+
     if (viewport->eglSurface) {
 	eglDestroySurface (m_eglContext.display, viewport->eglSurface);
     }
@@ -494,23 +500,7 @@ void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
     bool any = false;
 
     for (const auto& o : this->m_screens) {
-	bool shouldSetup = m_context.settings.general.screenBackgrounds.contains (o->name);
-
-	if (!shouldSetup) {
-	    for (const auto& spanGroup : m_context.settings.general.spanGroups) {
-		for (const auto& screen : spanGroup.screens) {
-		    if (screen == o->name) {
-			shouldSetup = true;
-			break;
-		    }
-		}
-		if (shouldSetup) {
-		    break;
-		}
-	    }
-	}
-
-	if (!shouldSetup) {
+	if (!isScreenRequested (o->name)) {
 	    continue;
 	}
 
@@ -540,6 +530,36 @@ void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
 
 	sLog.exception ("Cannot continue...");
     }
+
+    this->m_layerSurfacesReady = true;
+}
+
+bool WaylandOpenGLDriver::isScreenRequested (const std::string& name) const {
+    if (m_context.settings.general.screenBackgrounds.contains (name)) {
+	return true;
+    }
+
+    return std::ranges::any_of (m_context.settings.general.spanGroups, [&name] (const auto& spanGroup) {
+	return std::ranges::find (spanGroup.screens, name) != spanGroup.screens.end ();
+    });
+}
+
+void WaylandOpenGLDriver::setupLateOutputs () {
+    // KDE often drops and re-adds outputs while it applies the screen layout at login, and monitors can
+    // wake up after we started; the wallpapers stay registered by screen name, only the surface is missing
+    for (const auto& o : this->m_screens) {
+	if (o->layerSurface || !o->initialized || !isScreenRequested (o->name)) {
+	    continue;
+	}
+
+	sLog.out ("Output ", o->name, " appeared, attaching the wallpaper");
+
+	if (m_waylandContext.xdgOutputManager && !o->xdgOutput) {
+	    o->setupXdgOutput (m_waylandContext.xdgOutputManager);
+	}
+
+	o->setupLS ();
+    }
 }
 
 void WaylandOpenGLDriver::initGLEW () {
@@ -580,13 +600,17 @@ WaylandOpenGLDriver::~WaylandOpenGLDriver () {
 }
 
 void WaylandOpenGLDriver::dispatchEventQueue () {
-    static bool initialized = false;
+    if (this->m_layerSurfacesReady) {
+	this->setupLateOutputs ();
+    }
 
-    if (!initialized) {
-	initialized = true;
+    // rendering only continues from frame callbacks, so a surface that never got its first frame or whose
+    // callback the compositor dropped would stay blank forever; this also draws the very first frame
+    const auto now = std::chrono::steady_clock::now ();
 
-	for (const auto& viewport : this->getOutput ().getViewports () | std::views::values) {
-	    this->getApp ().update (viewport);
+    for (const auto& o : this->m_screens) {
+	if (o->layerSurface && now - o->lastSwap > MISSED_FRAME_TIMEOUT) {
+	    o->renderFrame ();
 	}
     }
 

+ 3 - 0
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h

@@ -116,6 +116,8 @@ private:
 
     void initWaylandRegistry ();
     void setupOutputLayerSurfaces ();
+    [[nodiscard]] bool isScreenRequested (const std::string& name) const;
+    void setupLateOutputs ();
     void initEGL ();
     void initGLEW ();
     void finishEGL () const;
@@ -124,6 +126,7 @@ private:
     wp_image_description_v1* m_hdrDescription = nullptr;
 
     uint32_t m_frameCounter = 0;
+    bool m_layerSurfacesReady = false;
     ApplicationContext& m_context;
     WaylandMouseInput m_mouseInput;
 

+ 336 - 110
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -1,4 +1,6 @@
 #include "CImage.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
 #include "CRenderable.h"
 
@@ -583,7 +585,7 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 
     for (uint32_t clipIndex = 0; clipIndex < clipCount; clipIndex++) {
 	PuppetAnimationClip clip;
-	reader.base ().seekg (sizeof (uint64_t), std::ios::cur); // animation id
+	reader.next (reinterpret_cast<char*> (&clip.id), sizeof (clip.id));
 	clip.name = reader.nextNullTerminatedString ();
 	clip.mode = reader.nextNullTerminatedString ();
 	clip.fps = reader.nextFloat ();
@@ -620,9 +622,11 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 	};
 
 	clip.boneTracks.resize (boneCount);
+	clip.boneAnimated.assign (boneCount, true);
 
 	for (uint32_t boneIndex = 0; boneIndex < boneCount && valid; boneIndex++) {
-	    (void)reader.nextUInt32 (); // track flags
+	    // bit 0 keeps the bone out of this clip, the blend masks it (2.8.42 sub_140261880)
+	    clip.boneAnimated[boneIndex] = (reader.nextUInt32 () & 1) == 0;
 	    const uint32_t trackBytes = reader.nextUInt32 ();
 
 	    if (trackBytes != sampleCount * 9 * sizeof (float)) {
@@ -638,6 +642,10 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 		keyframe.position = { reader.nextFloat (), reader.nextFloat (), reader.nextFloat () };
 		keyframe.rotation = { reader.nextFloat (), reader.nextFloat (), reader.nextFloat () };
 		keyframe.scale = { reader.nextFloat (), reader.nextFloat (), reader.nextFloat () };
+		// the loader turns the euler angles into a quaternion right away, same order as the matrices
+		keyframe.orientation = glm::angleAxis (keyframe.rotation.z, glm::vec3 (0.0f, 0.0f, 1.0f))
+		    * glm::angleAxis (keyframe.rotation.y, glm::vec3 (0.0f, 1.0f, 0.0f))
+		    * glm::angleAxis (keyframe.rotation.x, glm::vec3 (1.0f, 0.0f, 0.0f));
 		track.push_back (keyframe);
 	    }
 	}
@@ -884,6 +892,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->registerProperty ("alpha", *image.alpha->value);
     this->registerProperty ("color", *image.color->value);
     this->registerProperty ("parallaxDepth", *image.parallaxDepth->value);
+    this->registerProperty ("alignment", *image.alignmentName->value);
     this->registerEffectConstants (image.effects);
 
     auto scene_width = static_cast<float> (scene.getWidth ());
@@ -1102,28 +1111,72 @@ void CImage::updateTextures () const {
     }
 }
 
-bool CImage::containsScenePoint (const glm::vec2& point) const {
-    // m_pos is stored centered on the scene with y pointing down, x/z are left/right and y/w bottom/top
-    const float x = point.x - static_cast<float> (this->getScene ().getWidth ()) / 2.0f;
-    const float y = static_cast<float> (this->getScene ().getHeight ()) / 2.0f - point.y;
+void CImage::renderPassthroughChildren (const std::shared_ptr<const CFBO>& buffer) {
+    // the buffer holds the layer's quad (m_pos, copy pass texcoords: v = 1 at m_pos.y), children land in it through
+    // model = inverse (layer world), view = identity, ortho (-w/2, w/2, -h/2, h/2, -1000, 1000) (sub_1401ECB20).
+    // Fullscreen layers keep the scene's camera
+    glm::mat4 transform (1.0f);
+    glm::mat4 viewProjection = this->getScene ().getWorldViewProjection ();
+
+    if (!this->getImage ().model->fullscreen) {
+	const glm::vec3 center ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
+	const glm::vec3 halfExtent (
+	    (this->m_pos.z - this->m_pos.x) / 2.0f, (this->m_pos.y - this->m_pos.w) / 2.0f, 1000.0f
+	);
+
+	transform = glm::inverse (glm::scale (glm::translate (this->m_modelViewProjectionScreen, center), halfExtent));
+
+	// lights skip the model stack (volumetricsfront.vert only has g_ViewProjectionMatrix), so they see the bare
+	// ortho with the scene's y flip: their world position lands in the layer as if the layer sat at the origin
+	const glm::vec2 size = this->getSize ();
+	viewProjection = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	    * glm::ortho (-size.x / 2.0f, size.x / 2.0f, -size.y / 2.0f, size.y / 2.0f, -1000.0f, 1000.0f);
+    }
 
-    return x >= std::min (this->m_pos.x, this->m_pos.z) && x <= std::max (this->m_pos.x, this->m_pos.z)
-	&& y >= std::min (this->m_pos.y, this->m_pos.w) && y <= std::max (this->m_pos.y, this->m_pos.w);
+    this->getScene ().renderPassthroughChildren (
+	this->getId (),
+	{
+	    .fbo = buffer,
+	    .transform = transform,
+	    .alphaMax = !this->m_image.copyBackground->value->getBool (),
+	    .viewProjection = viewProjection,
+	}
+    );
 }
 
-glm::vec2 CImage::getSceneCenter () const {
-    return { (this->m_pos.x + this->m_pos.z) / 2.0f + static_cast<float> (this->getScene ().getWidth ()) / 2.0f,
-	     static_cast<float> (this->getScene ().getHeight ()) / 2.0f - (this->m_pos.y + this->m_pos.w) / 2.0f };
+bool CImage::hitTest (const glm::vec2& ndc) {
+    if (this->getImage ().model->fullscreen) {
+	return true;
+    }
+
+    this->updateScreenSpacePosition ();
+
+    const glm::vec3 center ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
+    const glm::vec2 half (
+	std::abs (this->m_pos.z - this->m_pos.x) / 2.0f, std::abs (this->m_pos.w - this->m_pos.y) / 2.0f
+    );
+
+    return Wallpapers::CScene::quadContainsPoint (glm::translate (this->m_modelViewProjectionScreen, center), half, ndc);
 }
 
-void CImage::refreshScenePosition () {
-    const auto sceneWidth = static_cast<float> (this->getScene ().getWidth ());
-    const auto sceneHeight = static_cast<float> (this->getScene ().getHeight ());
-    const auto transform = this->resolveTransform (this->getImage ());
-    glm::vec3 origin = transform.origin;
-    const glm::vec2 size = this->resolveGeometrySize (sceneWidth, sceneHeight, origin);
+glm::vec2 CImage::cursorLocalPosition (const glm::vec2& ndc) {
+    this->updateScreenSpacePosition ();
+
+    const glm::vec3 center ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
+    const auto point
+	= Wallpapers::CScene::quadPlanePoint (glm::translate (this->m_modelViewProjectionScreen, center), ndc);
+    const glm::vec2 extent (this->m_pos.z - this->m_pos.x, this->m_pos.y - this->m_pos.w);
+
+    if (!point.has_value () || extent.x == 0.0f || extent.y == 0.0f) {
+	return glm::vec2 (0.0f);
+    }
 
-    this->updateScenePosition (origin, size, transform.scale, sceneWidth, sceneHeight);
+    // m_pos runs y down from the top edge (.w), x from the texture's left edge (.x)
+    const glm::vec2 u (
+	(point->x + center.x - this->m_pos.x) / extent.x, (point->y + center.y - this->m_pos.w) / extent.y
+    );
+
+    return u * this->m_displaySize;
 }
 
 CImage::~CImage () {
@@ -1302,31 +1355,24 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		}
 
 		// puppets can declare several simultaneous "additive" layers (idle sway, blinking, hand
-		// movement, ...) - collect every matching one here; updatePuppetSkinning blend-weights
-		// them together per bone using each layer's own "blend" setting.
+		// movement, ...), updatePuppetSkinning blend-weights them per bone by each layer's "blend".
+		// A layer plays the clip whose id is its "animation" value, layers without a match are dropped
+		// (2.8.42 sub_1401FCC20); layer and clip names don't have to agree (3521337568's "j" plays "动画 1")
 		for (const auto& layer : this->getImage ().animationLayers) {
-		    auto match
-			= std::find_if (clips.begin (), clips.end (), [&layer] (const PuppetAnimationClip& clip) {
-			      return clip.name == layer->name;
+		    if (!layer->animation) {
+			continue;
+		    }
+		    const auto id = static_cast<uint64_t> (layer->animation->value->getInt ());
+		    const auto match
+			= std::find_if (clips.begin (), clips.end (), [id] (const PuppetAnimationClip& clip) {
+			      return clip.id == id;
 			  });
 		    if (match == clips.end ()) {
 			continue;
 		    }
 
 		    this->m_puppetActiveAnimations.push_back (
-			PuppetActiveAnimation { .clip = std::move (*match), .layer = layer.get () }
-		    );
-		}
-
-		if (this->m_puppetActiveAnimations.empty () && !clips.empty ()
-		    && !this->getImage ().animationLayers.empty ()) {
-		    sLog.out (
-			"No puppet animation clip name matched an animation layer for ",
-			*this->getImage ().model->puppet, ", defaulting to the first clip (", clips.front ().name, ")"
-		    );
-		    this->m_puppetActiveAnimations.push_back (
-			PuppetActiveAnimation { .clip = std::move (clips.front ()),
-						.layer = this->getImage ().animationLayers.front ().get () }
+			PuppetActiveAnimation { .clip = *match, .layer = layer.get () }
 		    );
 		}
 
@@ -1450,17 +1496,17 @@ void CImage::updatePuppetSkinning () {
 	return;
     }
 
-    // every matching, currently-visible animation layer plays and blends by its own "blend" weight,
-    // instead of only the first one. bindLocal from the MDLS array is deliberately not used as a
-    // rotation baseline - a clip's own sample is used directly, since some files carry bones whose
-    // MDLS bindLocal translation is wildly different from what their animation samples say, and
-    // falling back to it visibly detaches whatever that bone drives.
+    // WE's layer blend (2.8.42 sub_1401FDF90): each bone starts from its rest pose (the MDLS bind matrix as
+    // position, rotation and scale) and every visible layer in order blends towards its clip's sample
+    // (sub_1401F9020) or, when additive, adds the sample's difference from the rest pose (sub_1401F9820).
+    // Bones whose track is flagged off in a clip keep what they have.
     struct ActiveLayerSample {
 	const PuppetAnimationClip* clip;
 	uint32_t frame0;
 	uint32_t frame1;
 	float alpha;
-	float blend;
+	float weight;
+	bool additive;
     };
 
     std::vector<ActiveLayerSample> samples;
@@ -1470,33 +1516,48 @@ void CImage::updatePuppetSkinning () {
 	}
 
 	const auto& clip = candidate.clip;
-	const float duration = clip.fps > 0.0f ? static_cast<float> (clip.frameCount) / clip.fps : 0.0f;
-	const float rate = candidate.layer->rate->value->getFloat ();
+	if (clip.fps <= 0.0f || clip.frameCount == 0) {
+	    continue;
+	}
 
-	float frameFloat = 0.0f;
-	if (duration > 0.0f) {
-	    // "mirror" clips play forward then backward, so the end flows back into the start instead of snapping
-	    const bool mirror = std::ranges::equal (clip.mode, std::string_view ("mirror"), [] (char a, char b) {
+	const float frameTime = 1.0f / clip.fps;
+	const float duration = static_cast<float> (clip.frameCount) * frameTime;
+	const float rate = candidate.layer->rate->value->getFloat ();
+	const auto modeIs = [&clip] (std::string_view mode) {
+	    return std::ranges::equal (clip.mode, mode, [] (char a, char b) {
 		return std::tolower (static_cast<unsigned char> (a)) == b;
 	    });
-	    const float period = mirror ? duration * 2.0f : duration;
-	    float elapsed = std::fmod (g_Time * rate, period);
-	    if (elapsed < 0.0f) {
-		elapsed += period;
+	};
+
+	// sub_1401A9F60: "single" stops at its end and holds it, "mirror" plays forward then backward
+	float time = g_Time * rate;
+	if (modeIs ("single")) {
+	    time = std::clamp (time, 0.0f, duration);
+	} else if (modeIs ("mirror")) {
+	    time = std::fmod (time, duration * 2.0f);
+	    if (time < 0.0f) {
+		time += duration * 2.0f;
+	    }
+	    if (time > duration) {
+		time = duration * 2.0f - time;
 	    }
-	    if (mirror && elapsed > duration) {
-		elapsed = period - elapsed;
+	} else {
+	    time = std::fmod (time, duration);
+	    if (time < 0.0f) {
+		time += duration;
 	    }
-	    frameFloat = elapsed * clip.fps;
 	}
 
-	const auto frame0 = std::min (static_cast<uint32_t> (frameFloat), clip.frameCount);
+	// sub_140170580
+	const int lastFrame = static_cast<int> (clip.frameCount) - 1;
+	const int frame0 = std::clamp (static_cast<int> (time / frameTime), 0, lastFrame);
 	samples.push_back (
 	    ActiveLayerSample { .clip = &clip,
-				.frame0 = frame0,
-				.frame1 = std::min (frame0 + 1, clip.frameCount),
-				.alpha = frameFloat - static_cast<float> (frame0),
-				.blend = candidate.layer->blend->value->getFloat () }
+				.frame0 = static_cast<uint32_t> (frame0),
+				.frame1 = std::min (static_cast<uint32_t> (frame0 + 1), clip.frameCount),
+				.alpha = std::fmod (time, frameTime) / frameTime,
+				.weight = candidate.layer->blend->value->getFloat (),
+				.additive = candidate.layer->additive }
 	);
     }
 
@@ -1504,6 +1565,14 @@ void CImage::updatePuppetSkinning () {
 	return;
     }
 
+    // q and -q are the same rotation, blends take the one on the same side
+    const auto nlerp = [] (const glm::quat& a, glm::quat b, float t) {
+	if (glm::dot (a, b) < 0.0f) {
+	    b = -b;
+	}
+	return glm::normalize (a * (1.0f - t) + b * t);
+    };
+
     std::vector<int> animatedParents (this->m_puppetBones.size ());
     std::vector<glm::mat4> animatedLocals (this->m_puppetBones.size ());
 
@@ -1517,45 +1586,48 @@ void CImage::updatePuppetSkinning () {
 	    continue;
 	}
 
-	const glm::vec3 bindPosition (bone.bindLocal[3]);
-	glm::vec3 position = bindPosition;
-	bool positionBased = false;
-	glm::vec3 rotation (0.0f);
-	glm::vec3 scale (1.0f);
+	const glm::vec3 restPosition (bone.bindLocal[3]);
+	const glm::vec3 restScale (
+	    glm::length (glm::vec3 (bone.bindLocal[0])), glm::length (glm::vec3 (bone.bindLocal[1])),
+	    glm::length (glm::vec3 (bone.bindLocal[2]))
+	);
+	const glm::quat restOrientation = glm::normalize (glm::quat_cast (glm::mat3 (
+	    glm::vec3 (bone.bindLocal[0]) / restScale.x, glm::vec3 (bone.bindLocal[1]) / restScale.y,
+	    glm::vec3 (bone.bindLocal[2]) / restScale.z
+	)));
+
+	glm::vec3 position = restPosition;
+	glm::vec3 scale = restScale;
+	glm::quat orientation = restOrientation;
 
-	// each layer contributes a blend-weighted delta from the shared baseline (bind position, zero
-	// rotation, unit scale) rather than replacing it outright
 	for (const auto& sample : samples) {
-	    if (i >= sample.clip->boneTracks.size () || sample.clip->boneTracks[i].size () <= sample.frame1) {
+	    const auto& clip = *sample.clip;
+	    if (i >= clip.boneTracks.size () || clip.boneTracks[i].size () <= sample.frame1
+		|| (i < clip.boneAnimated.size () && !clip.boneAnimated[i])) {
 		continue;
 	    }
 
-	    const auto& track = sample.clip->boneTracks[i];
-	    const glm::vec3 trackPosition
-		= lerp (track[sample.frame0].position, track[sample.frame1].position, sample.alpha);
-	    const glm::vec3 trackRotation
-		= lerp (track[sample.frame0].rotation, track[sample.frame1].rotation, sample.alpha);
-	    const glm::vec3 trackScale = lerp (track[sample.frame0].scale, track[sample.frame1].scale, sample.alpha);
-
-	    // deltas are measured from the clip's own first frame, some rigs carry a static track pose far from
-	    // bindLocal
-	    const glm::vec3 restPosition = track[0].position;
-	    if (!positionBased) {
-		position = restPosition;
-		positionBased = true;
+	    const auto& from = clip.boneTracks[i][sample.frame0];
+	    const auto& to = clip.boneTracks[i][sample.frame1];
+	    const glm::vec3 samplePosition = lerp (from.position, to.position, sample.alpha);
+	    const glm::vec3 sampleScale = lerp (from.scale, to.scale, sample.alpha);
+	    const glm::quat sampleOrientation = nlerp (from.orientation, to.orientation, sample.alpha);
+	    const float weight = sample.weight;
+
+	    if (sample.additive) {
+		position += (samplePosition - restPosition) * weight;
+		scale += (sampleScale - restScale) * weight;
+		const glm::quat delta = glm::conjugate (restOrientation) * sampleOrientation;
+		orientation = orientation * nlerp (glm::quat (1.0f, 0.0f, 0.0f, 0.0f), delta, weight);
+	    } else {
+		position = position * (1.0f - weight) + samplePosition * weight;
+		scale = scale * (1.0f - weight) + sampleScale * weight;
+		orientation = nlerp (orientation, sampleOrientation, weight);
 	    }
-	    position += sample.blend * (trackPosition - restPosition);
-	    rotation += sample.blend * trackRotation;
-	    scale += sample.blend * (trackScale - glm::vec3 (1.0f));
 	}
 
-	glm::mat4 local = glm::translate (glm::mat4 (1.0f), position);
-	local = glm::rotate (local, rotation.z, glm::vec3 (0.0f, 0.0f, 1.0f));
-	local = glm::rotate (local, rotation.y, glm::vec3 (0.0f, 1.0f, 0.0f));
-	local = glm::rotate (local, rotation.x, glm::vec3 (1.0f, 0.0f, 0.0f));
-	local = glm::scale (local, scale);
-
-	animatedLocals[i] = local;
+	animatedLocals[i] = glm::translate (glm::mat4 (1.0f), position) * glm::mat4_cast (orientation)
+	    * glm::scale (glm::mat4 (1.0f), scale);
     }
 
     const std::vector<glm::mat4> worldAnimated = this->m_puppetHasPhysics
@@ -1743,10 +1815,10 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 	    // subset of triangles ends up on the wrong side, which looks like patchy missing geometry.
 	    glDisable (GL_CULL_FACE);
 	},
-	[this] () {
-	    GLint currentFramebuffer = 0;
-	    glGetIntegerv (GL_DRAW_FRAMEBUFFER_BINDING, &currentFramebuffer);
-	    if (currentFramebuffer != static_cast<GLint> (this->getScene ().getFBO ()->getFramebuffer ())) {
+	[this, pass] () {
+	    // a mesh pass into the layer's own buffer starts it fresh, one meant for the scene may be going into a
+	    // passthrough layer's buffer right now, which holds its other children
+	    if (pass->getDestination () != this->getScene ().getFBO ()) {
 		GLfloat previousClearColor[4] = {};
 		glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
 		glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
@@ -1915,8 +1987,15 @@ void CImage::setup () {
 	    && std::ranges::find (object->dependencies, this->getImage ().id) != object->dependencies.end ();
     });
 
-    // passthrough without effects has nothing to draw unless another layer reads its _rt_imageLayerComposite
-    if (this->m_image.model->passthrough && this->m_image.effects.empty () && !this->m_readByOtherLayer) {
+    this->m_hasPassthroughChildren = this->m_image.model->passthrough
+	&& std::ranges::any_of (this->getScene ().getScene ().objects, [this] (const auto& object) {
+	       return object->parent == this->getImage ().id;
+	   });
+
+    // passthrough without effects has nothing to draw unless another layer reads its _rt_imageLayerComposite or it
+    // has children to draw into its buffer
+    if (this->m_image.model->passthrough && this->m_image.effects.empty () && !this->m_readByOtherLayer
+	&& !this->m_hasPassthroughChildren) {
 	return;
     }
 
@@ -2021,7 +2100,9 @@ void CImage::setup () {
     const bool fog = this->getScene ().hasDistanceFog () || this->getScene ().hasHeightFog ();
     this->m_fogPass = nullptr;
 
-    if (!debug.baseOnly && (colorBlendMode > 0 || copyForReaders || fog)) {
+    // children go into the layer's buffer, which then needs a composite onto the scene even without effects
+    // (sub_1401E8AA0 with children, sub_140208670)
+    if (!debug.baseOnly && (colorBlendMode > 0 || copyForReaders || fog || this->m_hasPassthroughChildren)) {
 	// scenes from version 3 on composite with genericimage4, the one with fog (sub_1401EBBC0)
 	const auto& project = this->getScene ().getScene ().project;
 	this->m_materials.colorBlending.material = MaterialParser::load (
@@ -2202,6 +2283,7 @@ void CImage::setupPasses () {
 
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setViewProjectionMatrix (&this->m_viewProjectionMatrix);
+	pass->setLayerModelMatrix (&this->m_layerModelMatrix);
 	pass->setEffectTextureProjectionMatrix (
 	    &this->m_effectTextureProjection, &this->m_effectTextureProjectionInverse
 	);
@@ -2245,6 +2327,13 @@ void CImage::setupPasses () {
 	pass->setTexCoord (texcoord);
 	pass->setModelViewProjectionMatrix (projection);
 	pass->setModelViewProjectionMatrixInverse (inverseProjection);
+	// what WE's intermediate passes see as g_EffectModelViewProjectionMatrix: their geometry where the layer
+	// is on screen (sub_1401EBF60), the final pass keeps its own MVP
+	pass->setEffectModelViewProjectionMatrix (
+	    projection == &this->m_modelViewProjectionCopy	? &this->m_effectModelViewProjectionCopy
+		: projection == &this->m_modelViewProjectionPass ? &this->m_effectModelViewProjectionPass
+								    : nullptr
+	);
 
 	texcoord = this->getTexCoordPass ();
 
@@ -2337,7 +2426,8 @@ void CImage::render () {
 	this->rebuildActivePasses ();
     }
 
-    if (this->m_image.model->passthrough && !this->m_hasActiveEffectPass && !this->m_readByOtherLayer) {
+    if (this->m_image.model->passthrough && !this->m_hasActiveEffectPass && !this->m_readByOtherLayer
+	&& !this->m_hasPassthroughChildren) {
 	return;
     }
 
@@ -2373,16 +2463,23 @@ void CImage::render () {
     const auto end = this->m_passes.end ();
 
     for (; cur != end; ++cur) {
-	if (std::next (cur) == end) {
+	// the scene keeps its own alpha, but a hidden dependency's last pass fills the _a buffer other layers
+	// read and needs its alpha (2938612768's audio cover drawn through the background's blend effects)
+	if (std::next (cur) == end && this->m_passesDrawToScreen) {
 	    glColorMask (true, true, true, false);
 	}
 
 	if (*cur == skippedBasePass && std::next (cur) != end) {
 	    (*cur)->clearDestination ();
-	    continue;
+	} else {
+	    (*cur)->render ();
 	}
 
-	(*cur)->render ();
+	if (this->m_hasPassthroughChildren && *cur == this->m_allPasses.front () && std::next (cur) != end) {
+	    this->renderPassthroughChildren ((*cur)->getDestination ());
+	    // the children's final passes leave the scene's rgb only mask behind
+	    glColorMask (true, true, true, true);
+	}
     }
 
     // restore alpha writes - CParticle::render() never resets glColorMask, so leaving this
@@ -2405,12 +2502,30 @@ const float& CImage::getAlpha () const {
 }
 
 const glm::vec3& CImage::getColor () const {
+    // a solid layer showing an image the user picked draws it untinted, its color only tints the plain
+    // white fill (checked against WE with 3765586324's local file background, black and white color alike)
+    const glm::vec3 color = this->showsUserTextureOnSolidLayer () ? glm::vec3 (1.0f) : this->m_image.color->value->getVec3 ();
     // the object's brightness only scales its color in HDR scene rendering (sub_140207740)
-    m_colorCache = this->m_image.color->value->getVec3 ()
-	* (this->getScene ().isHDR () ? this->m_image.brightness->value->getFloat () : 1.0f);
+    m_colorCache = color * (this->getScene ().isHDR () ? this->m_image.brightness->value->getFloat () : 1.0f);
     return m_colorCache;
 }
 
+bool CImage::showsUserTextureOnSolidLayer () const {
+    if (!this->m_image.model->solidlayer || this->m_image.model->material->passes.empty ()) {
+	return false;
+    }
+
+    const auto& properties = this->getScene ().getScene ().project.properties;
+    for (const auto& [index, propertyName] : (*this->m_image.model->material->passes.begin ())->usertextures) {
+	const auto it = properties.find (propertyName);
+	if (it != properties.end () && !it->second->is<Data::Model::PropertyUserShortcut> () && !it->second->getString ().empty ()) {
+	    return true;
+	}
+    }
+
+    return false;
+}
+
 const glm::vec4& CImage::getColor4 () const {
     // "version" 2 materials take color and alpha together through g_Color4
     m_color4Cache = glm::vec4 (this->getColor (), this->getAlpha ());
@@ -2455,12 +2570,13 @@ void CImage::updateScenePosition (
     }
 
     const glm::vec2 scaledSize = displaySize * glm::vec2 (scale);
+    this->m_displaySize = displaySize;
     this->m_pos.x = origin.x - (scaledSize.x / 2.0f);
     this->m_pos.w = origin.y + (scaledSize.y / 2.0f);
     this->m_pos.z = origin.x + (scaledSize.x / 2.0f);
     this->m_pos.y = origin.y - (scaledSize.y / 2.0f);
 
-    const uint32_t alignment = this->getImage ().alignment;
+    const uint32_t alignment = Data::Parsers::ObjectParser::parseAlignment (this->getImage ().alignmentName->value->getString ());
 
     if (alignment & ImageAlignment_Top) {
 	this->m_pos.y -= scaledSize.y / 2.0f;
@@ -2593,6 +2709,70 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
 }
 } // namespace
 
+glm::mat4 CImage::ancestorTiltCorrection () const {
+    // the quad above is placed with the parent chain folded in 2D (origin, scale, z angle). WE multiplies the full
+    // world matrix into the model stack instead (sub_1401E8AA0), so parents' x/y angles tilt their children too:
+    // map from the chain without those angles to the full one, in this centered y down space
+    const auto& scene = this->getScene ();
+    const auto localMatrix = [] (const Object& object, bool tilt) {
+	glm::vec3 scale = object.groupScale->value->getVec3 ();
+	glm::vec3 angles = object.groupAngles->value->getVec3 ();
+
+	if (object.is<Image> ()) {
+	    scale = object.as<Image> ()->scale->value->getVec3 ();
+	    angles = object.as<Image> ()->angles->value->getVec3 ();
+	} else if (object.is<Particle> ()) {
+	    scale = object.as<Particle> ()->scale->value->getVec3 ();
+	    angles = object.as<Particle> ()->angles->value->getVec3 ();
+	} else if (object.is<Text> ()) {
+	    scale = object.as<Text> ()->scale->value->getVec3 ();
+	}
+
+	glm::mat4 local = glm::translate (glm::mat4 (1.0f), object.origin->value->getVec3 ());
+	local = glm::rotate (local, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
+
+	if (tilt) {
+	    local = glm::rotate (local, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+	    local = glm::rotate (local, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+	}
+
+	return std::pair { glm::scale (local, scale), angles.x != 0.0f || angles.y != 0.0f };
+    };
+
+    glm::mat4 full (1.0f);
+    glm::mat4 flat (1.0f);
+    bool tilted = false;
+    const Object* current = &this->getImage ();
+
+    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
+	// attachments follow a puppet bone, which the folded chain resolves on its own
+	if (current->attachment.has_value ()) {
+	    return glm::mat4 (1.0f);
+	}
+
+	const CObject* parent = scene.getObject (current->parent.value ());
+
+	if (parent == nullptr) {
+	    break;
+	}
+
+	current = &parent->getObject ();
+	const auto [withTilt, hasTilt] = localMatrix (*current, true);
+	full = withTilt * full;
+	flat = localMatrix (*current, false).first * flat;
+	tilted |= hasTilt;
+    }
+
+    if (!tilted) {
+	return glm::mat4 (1.0f);
+    }
+
+    const glm::mat4 toScreen = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	* glm::translate (glm::mat4 (1.0f), glm::vec3 (-scene.getWidth () / 2.0f, -scene.getHeight () / 2.0f, 0.0f));
+
+    return toScreen * full * glm::inverse (flat) * glm::inverse (toScreen);
+}
+
 void CImage::updateScreenSpacePosition () {
     const ResolvedTransform transform = this->updateGeometryBuffers ();
 
@@ -2634,16 +2814,41 @@ void CImage::updateScreenSpacePosition () {
 	}
     }
 
+    rotModel = this->ancestorTiltCorrection () * rotModel;
+
     glm::mat4 mvp = this->getViewProjection () * rotModel;
     const bool fullscreen = this->getImage ().model->fullscreen;
+    const auto& camera = this->getScene ().getCamera ();
 
     // WE draws fullscreen layers with an identity transform, camera movement and parallax don't reach them
     if (fullscreen) {
-	mvp = this->getScene ().getCamera ().getFullscreenProjection ();
+	mvp = camera.getFullscreenProjection ();
+    } else if (camera.isPerspective () && !this->m_hasPuppetMesh) {
+	// 3D scenes: the quad (size in world units) goes through the scene camera with the image's world matrix like
+	// any other object (sub_1401E8AA0), "perspective" layers through their own camera. The vertices here are laid
+	// out in the 2D scene space, this takes them back to the object's own space first
+	const glm::vec2 size = this->getSize ();
+	const glm::vec2 extent (this->m_pos.z - this->m_pos.x, this->m_pos.w - this->m_pos.y);
+
+	if (extent.x != 0.0f && extent.y != 0.0f) {
+	    const glm::vec3 center (
+		(this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f
+	    );
+	    const glm::mat4 toLocal = glm::translate (
+		glm::scale (glm::mat4 (1.0f), glm::vec3 (size / extent, 0.0f)), -center
+	    );
+	    const glm::mat4 viewProjection = this->getImage ().perspective->value->getBool ()
+		? camera.getPerspectiveLayerViewProjection ()
+		: this->getScene ().getWorldViewProjection ();
+
+	    mvp = viewProjection * this->getScene ().objectWorldMatrix (this->getImage ()) * toLocal;
+	}
     }
 
-    // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
-    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool () && !fullscreen) {
+    // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement(). WE's camera parallax only
+    // exists in orthographic scenes (scene flags & 0x108, sub_14018AAC0)
+    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool () && !fullscreen
+	&& !camera.isPerspective ()) {
 	const glm::vec2 offset = this->getScene ().getParallaxOffset (this->getImage ());
 	float x = offset.x;
 	float y = offset.y;
@@ -2662,8 +2867,11 @@ void CImage::updateScreenSpacePosition () {
 	    y = clampParallaxAxis (y, this->m_pos.y, this->m_pos.w, sceneHeight);
 	}
 
-	mvp = glm::translate (mvp, { x, y, 0.0f });
-	rotModel = glm::translate (rotModel, { x, y, 0.0f });
+	// WE translates the view (sub_14018AAC0), so its offset isn't turned by the layer's own rotation. The clamp's
+	// correction is measured on the unrotated quad and stays inside the rotation
+	rotModel = glm::translate (glm::mat4 (1.0f), { offset.x, offset.y, 0.0f }) * rotModel
+	    * glm::translate (glm::mat4 (1.0f), { x - offset.x, y - offset.y, 0.0f });
+	mvp = this->getViewProjection () * rotModel;
     }
 
     this->updateLightingTransform (rotModel);
@@ -2673,6 +2881,24 @@ void CImage::updateScreenSpacePosition () {
 	this->m_modelViewProjectionScreenInverse = glm::inverse (mvp);
     }
     this->m_modelViewProjectionScreen = mvp;
+    this->m_layerModelMatrix = this->getScene ().objectWorldMatrix (this->getImage ());
+    // buffer space (0..size, y = size at m_pos.y like the copy quad) onto the layer's scene quad, then the screen
+    const glm::vec2 bufferSize = this->m_size;
+    if (bufferSize.x > 0.0f && bufferSize.y > 0.0f) {
+	glm::mat4 bufferToScene (1.0f);
+	bufferToScene[0][0] = (this->m_pos.z - this->m_pos.x) / bufferSize.x;
+	bufferToScene[1][1] = (this->m_pos.y - this->m_pos.w) / bufferSize.y;
+	bufferToScene[3][0] = this->m_pos.x;
+	bufferToScene[3][1] = this->m_pos.w;
+	this->m_effectModelViewProjectionCopy = mvp * bufferToScene;
+	// later passes draw WE's unit quad (-1..1), sub_1401EBF60 scales it by half the layer size first
+	glm::mat4 unitToBuffer (1.0f);
+	unitToBuffer[0][0] = bufferSize.x * 0.5f;
+	unitToBuffer[1][1] = bufferSize.y * 0.5f;
+	unitToBuffer[3][0] = bufferSize.x * 0.5f;
+	unitToBuffer[3][1] = bufferSize.y * 0.5f;
+	this->m_effectModelViewProjectionPass = this->m_effectModelViewProjectionCopy * unitToBuffer;
+    }
     this->updateEffectTextureProjection ();
     if (this->getImage ().model->passthrough) {
 	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;

+ 26 - 7
src/WallpaperEngine/Render/Objects/CImage.h

@@ -11,6 +11,7 @@
 #include "PuppetPhysics.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
+#include <glm/gtc/quaternion.hpp>
 #include <glm/mat4x4.hpp>
 #include <glm/vec3.hpp>
 #include <glm/vec4.hpp>
@@ -43,16 +44,22 @@ struct PuppetKeyframe {
     glm::vec3 position {};
     glm::vec3 rotation {};
     glm::vec3 scale { 1.0f };
+    /** rotation as WE blends it, Rz * Ry * Rx */
+    glm::quat orientation { 1.0f, 0.0f, 0.0f, 0.0f };
 };
 
 /** A baked animation clip: one keyframe track per bone, sampled at a fixed rate */
 struct PuppetAnimationClip {
+    /** what animationlayers[].animation refers to */
+    uint64_t id = 0;
     std::string name;
     std::string mode;
     float fps = 30.0f;
     uint32_t frameCount = 0;
     /** [boneIndex][sampleIndex], each track has frameCount+1 samples */
     std::vector<std::vector<PuppetKeyframe>> boneTracks;
+    /** per bone, false when its track flags have bit 0 set: the clip leaves that bone alone */
+    std::vector<bool> boneAnimated;
 };
 
 /** A named point on a puppet's rig that other objects can follow via scene.json's "attachment" field */
@@ -81,13 +88,13 @@ public:
 
     /** Refreshes the image's own texture plus any video a pass pulled in from a user texture slot */
     void updateTextures () const;
-    /** Whether a point in scene coordinates (origin bottom-left, y up) lies inside the layer's on-screen box, rotation
-     * ignored */
-    [[nodiscard]] bool containsScenePoint (const glm::vec2& point) const;
-    /** Center of the layer's on-screen box in scene coordinates (origin bottom-left, y up) */
-    [[nodiscard]] glm::vec2 getSceneCenter () const;
-    /** Moves the on-screen box to the current origin/scale without touching GL */
-    void refreshScenePosition ();
+    /** Cursor hit test (sub_14019DBB0): the layer's quad as it is drawn, rotation and parallax included, ndc in the
+     *  scene buffer's clip space. Fullscreen layers are always hit */
+    [[nodiscard]] bool hitTest (const glm::vec2& ndc);
+    /** A cursor event's localPosition: (u * width, (1 - v) * height) of the unscaled layer, from its top left, where
+     *  the cursor meets the quad's plane (sub_14019DBB0), off the quad too. Zero when it doesn't meet the plane */
+    [[nodiscard]] glm::vec2 cursorLocalPosition (const glm::vec2& ndc);
+    void renderPassthroughChildren (const std::shared_ptr<const CFBO>& buffer);
     [[nodiscard]] const Image& getImage () const;
     [[nodiscard]] glm::vec2 getSize () const;
     /** Another object samples this layer's composite FBO, so its passes run even while it's hidden */
@@ -103,6 +110,8 @@ public:
     [[nodiscard]] const float& getUserAlpha () const override;
     [[nodiscard]] const float& getAlpha () const override;
     [[nodiscard]] const glm::vec3& getColor () const override;
+    /** a solid layer instance whose user texture slot the user filled */
+    [[nodiscard]] bool showsUserTextureOnSolidLayer () const;
     [[nodiscard]] const glm::vec4& getColor4 () const override;
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
 
@@ -133,6 +142,7 @@ protected:
     [[nodiscard]] bool effectVisibilityChanged () const;
 
     void updateScreenSpacePosition ();
+    [[nodiscard]] glm::mat4 ancestorTiltCorrection () const;
     void updateEffectTextureProjection ();
     void updateLightingTransform (const glm::mat4& sceneTransform);
 
@@ -226,6 +236,11 @@ private:
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};
+    /** effect passes: their buffer's geometry mapped to where the layer is on screen */
+    glm::mat4 m_effectModelViewProjectionCopy { 1.0f };
+    /** WE's object world matrix (y up, scene pixels), shaders read g_LayerModelMatrix for the layer's scale */
+    glm::mat4 m_layerModelMatrix { 1.0f };
+    glm::mat4 m_effectModelViewProjectionPass { 1.0f };
     glm::mat4 m_modelViewProjectionCopy = {};
     glm::mat4 m_modelViewProjectionScreenInverse = {};
     glm::mat4 m_modelViewProjectionPassInverse = {};
@@ -264,6 +279,8 @@ private:
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
     glm::vec4 m_pos = {};
+    /** The quad's size before the layer's scale, what m_pos spans */
+    glm::vec2 m_displaySize = {};
     glm::vec3 m_sceneCenter = {};
     /** Lit passes: scene/copy space vertices -> WE world (y up, bottom left origin), see CPass::setLightingTransform */
     glm::mat4 m_lightingSceneModel { 1.0f };
@@ -279,6 +296,8 @@ private:
     bool m_initialized = false;
     bool m_isDependency = false;
     bool m_readByOtherLayer = false;
+    /** A passthrough layer with objects under it, it draws them into its buffer after the base pass */
+    bool m_hasPassthroughChildren = false;
 
     struct {
 	struct {

+ 77 - 4
src/WallpaperEngine/Render/Objects/CMesh.cpp

@@ -1,6 +1,8 @@
 #include "CMesh.h"
 
+#include <algorithm>
 #include <cstring>
+#include <limits>
 #include <glm/gtc/matrix_transform.hpp>
 
 #include "WallpaperEngine/Data/Model/Object.h"
@@ -65,6 +67,14 @@ public:
 	return value;
     }
 
+    float f32 () {
+	const uint32_t bits = this->u32 ();
+	float value;
+
+	std::memcpy (&value, &bits, sizeof (value));
+	return value;
+    }
+
     uint8_t u8 () {
 	const uint8_t value = this->has (1) ? static_cast<uint8_t> (this->m_data[this->m_offset]) : 0;
 	this->m_offset++;
@@ -101,7 +111,9 @@ private:
     size_t m_offset = 0;
 };
 
-std::vector<MdlMesh> readMdlMeshes (const std::vector<char>& data) {
+// bounds is the union of every mesh's box (sub_1402617C0), what the cursor hit test uses; files before version 17 have
+// no boxes and leave it empty (min > max)
+std::vector<MdlMesh> readMdlMeshes (const std::vector<char>& data, glm::vec3& boundsMin, glm::vec3& boundsMax) {
     MdlReader reader (data);
     const std::string magic = reader.string ();
 
@@ -115,6 +127,9 @@ std::vector<MdlMesh> readMdlMeshes (const std::vector<char>& data) {
     const uint32_t meshCount = reader.u32 ();
     std::vector<MdlMesh> meshes;
 
+    boundsMin = glm::vec3 (std::numeric_limits<float>::max ());
+    boundsMax = glm::vec3 (std::numeric_limits<float>::lowest ());
+
     for (uint32_t index = 0; index < meshCount; index++) {
 	MdlMesh mesh;
 
@@ -135,9 +150,19 @@ std::vector<MdlMesh> readMdlMeshes (const std::vector<char>& data) {
 		reader.u32 ();
 	    }
 
-	    // bounding box
 	    if (version >= 17) {
-		reader.skip (sizeof (float) * 6);
+		glm::vec3 min;
+		glm::vec3 max;
+
+		for (int axis = 0; axis < 3; axis++) {
+		    min[axis] = reader.f32 ();
+		}
+		for (int axis = 0; axis < 3; axis++) {
+		    max[axis] = reader.f32 ();
+		}
+
+		boundsMin = glm::min (boundsMin, min);
+		boundsMax = glm::max (boundsMax, max);
 	    }
 
 	    if (version >= 15) {
@@ -396,7 +421,7 @@ void CMesh::setup () {
     const auto stream = project.assetLocator->read (this->m_mesh.model);
     const std::vector<char> data { std::istreambuf_iterator<char> (*stream), std::istreambuf_iterator<char> () };
 
-    for (auto& mesh : readMdlMeshes (data)) {
+    for (auto& mesh : readMdlMeshes (data, this->m_boundsMin, this->m_boundsMax)) {
 	auto material = MaterialParser::load (project, mesh.material);
 
 	if (material == nullptr || material->passes.empty ()) {
@@ -459,6 +484,14 @@ void CMesh::updateMatrices () {
     this->m_modelMatrix = model;
     this->m_normalMatrix = glm::mat3 (model);
     this->m_viewProjection = scene.getWorldViewProjection ();
+
+    // orthographic scenes with camera parallax translate the view of every object, models too (sub_14018AAC0),
+    // by the same offset images get; it's in the y down space, the view here is WE's y up world
+    if (!camera.isPerspective () && scene.getScene ().camera.parallax.enabled->value->getBool ()) {
+	const glm::vec2 offset = scene.getParallaxOffset (this->m_mesh);
+	this->m_viewProjection = glm::translate (this->m_viewProjection, glm::vec3 (offset.x, -offset.y, 0.0f));
+    }
+
     this->m_modelViewProjection = this->m_viewProjection * model;
     this->m_modelViewProjectionInverse = glm::inverse (this->m_modelViewProjection);
 
@@ -471,6 +504,46 @@ void CMesh::updateMatrices () {
     }
 }
 
+bool CMesh::hitTest (const glm::vec2& ndc) const { return this->boxEntry (ndc).has_value (); }
+
+glm::vec3 CMesh::cursorLocalPosition (const glm::vec2& ndc) const {
+    const auto entry = this->boxEntry (ndc);
+
+    return entry.has_value () ? entry.value () - (this->m_boundsMax - this->m_boundsMin) * 0.5f : glm::vec3 (0.0f);
+}
+
+std::optional<glm::vec3> CMesh::boxEntry (const glm::vec2& ndc) const {
+    if (this->m_boundsMax.x <= this->m_boundsMin.x) {
+	return std::nullopt;
+    }
+
+    const auto& scene = this->getScene ();
+    const glm::mat4 toModel
+	= glm::inverse (scene.getWorldViewProjection () * scene.objectWorldMatrix (this->m_mesh));
+    const glm::vec4 nearPoint = toModel * glm::vec4 (ndc, -1.0f, 1.0f);
+    const glm::vec4 farPoint = toModel * glm::vec4 (ndc, 1.0f, 1.0f);
+    const glm::vec3 origin = glm::vec3 (nearPoint) / nearPoint.w;
+    const glm::vec3 direction = glm::vec3 (farPoint) / farPoint.w - origin;
+    const glm::vec3 extent = this->m_boundsMax - this->m_boundsMin;
+    float enter = std::numeric_limits<float>::lowest ();
+    float leave = std::numeric_limits<float>::max ();
+
+    // slabs, no t >= 0 check: WE tests the whole line
+    for (int axis = 0; axis < 3; axis++) {
+	const float a = (0.0f - origin[axis]) / direction[axis];
+	const float b = (extent[axis] - origin[axis]) / direction[axis];
+
+	enter = std::max (enter, std::min (a, b));
+	leave = std::min (leave, std::max (a, b));
+    }
+
+    if (leave < enter) {
+	return std::nullopt;
+    }
+
+    return origin + direction * enter;
+}
+
 const Mesh& CMesh::getMesh () const { return this->m_mesh; }
 
 const glm::mat4& CMesh::getModelMatrix () const { return this->m_modelMatrix; }

+ 10 - 0
src/WallpaperEngine/Render/Objects/CMesh.h

@@ -4,6 +4,7 @@
 
 #include <glm/mat3x3.hpp>
 #include <glm/mat4x4.hpp>
+#include <optional>
 
 namespace WallpaperEngine::Render::Objects {
 using namespace WallpaperEngine::Data::Model;
@@ -27,10 +28,17 @@ public:
     [[nodiscard]] const glm::mat4& getModelViewProjectionMatrix () const;
     [[nodiscard]] const glm::mat4& getModelViewProjectionMatrixInverse () const;
     [[nodiscard]] const glm::vec3& getEyePosition () const;
+    /** Cursor hit test (sub_140185520): the line through the cursor against the model's bounds in model space, a box
+     *  from 0 to the bounds' extent (WE doesn't offset it by the minimum). ndc is in the scene buffer's clip space */
+    [[nodiscard]] bool hitTest (const glm::vec2& ndc) const;
+    /** A cursor event's localPosition: where the line enters that box, relative to the box's center, zero on a miss */
+    [[nodiscard]] glm::vec3 cursorLocalPosition (const glm::vec2& ndc) const;
 
     class Part;
 
 private:
+    /** The model space point where the line through ndc enters the hit box, see hitTest */
+    [[nodiscard]] std::optional<glm::vec3> boxEntry (const glm::vec2& ndc) const;
     void updateMatrices ();
 
     const Mesh& m_mesh;
@@ -43,5 +51,7 @@ private:
     glm::mat4 m_modelViewProjection = glm::mat4 (1.0f);
     glm::mat4 m_modelViewProjectionInverse = glm::mat4 (1.0f);
     glm::vec3 m_eyePosition = glm::vec3 (0.0f);
+    glm::vec3 m_boundsMin = glm::vec3 (0.0f);
+    glm::vec3 m_boundsMax = glm::vec3 (0.0f);
 };
 } // namespace WallpaperEngine::Render::Objects

+ 36 - 19
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -4194,16 +4194,7 @@ void CParticle::renderSprites () {
 
     updateMatrices ();
 
-    // REFRACT: blit current scene content into the copy FBO first, giving the shader a
-    // snapshot of what's behind the particles without a read/write feedback loop
-    if (m_hasRefract && m_refractFBO) {
-	auto sceneFBO = getScene ().getFBO ();
-	GLint w = static_cast<GLint> (sceneFBO->getRealWidth ());
-	GLint h = static_cast<GLint> (sceneFBO->getRealHeight ());
-	glBindFramebuffer (GL_READ_FRAMEBUFFER, sceneFBO->getFramebuffer ());
-	glBindFramebuffer (GL_DRAW_FRAMEBUFFER, m_refractFBO->getFramebuffer ());
-	glBlitFramebuffer (0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
-    }
+    copyRefractSource ();
 
     // ComputeParticleTrailTangents produces a right vector with a Z component (from
     // cross(eyeDirection, velocity), where eyeDirection has an XY offset from the model
@@ -4224,6 +4215,40 @@ void CParticle::renderSprites () {
 
 float CParticle::ropeUVScale () const { return m_ropeUVScale != 0.0f ? m_ropeUVScale : 1.0f; }
 
+void CParticle::copyRefractSource () const {
+    if (!m_hasRefract || !m_refractFBO) {
+	return;
+    }
+
+    // WE resolves whatever target is bound into _rt_FullFrameBuffer (sub_1402366F0 -> sub_1400D3310). Under a
+    // passthrough layer that is the layer's buffer, and the copy only works when it has _rt_FullFrameBuffer's size
+    // (the window's); both always share a color format (sub_1400D2A20: RGBA8, RGBA16F with renderer flag 0x2000).
+    // Otherwise the texture keeps what it had, the scene as it was before the layer, which is our scene buffer now
+    std::shared_ptr<const CFBO> source = getScene ().getFBO ();
+
+    if (const auto* layer = getScene ().getLayerTarget (); layer != nullptr) {
+	const glm::ivec2 layerSize { std::max (layer->fbo->getRealWidth (), 4u),
+				     std::max (layer->fbo->getRealHeight (), 4u) };
+	const glm::ivec2 outputSize = glm::max (getScene ().getOutputSize (), glm::ivec2 (2));
+
+	if (layerSize == outputSize) {
+	    source = layer->fbo;
+	}
+    }
+
+    const GLint sw = static_cast<GLint> (source->getRealWidth ());
+    const GLint sh = static_cast<GLint> (source->getRealHeight ());
+    const GLint dw = static_cast<GLint> (m_refractFBO->getRealWidth ());
+    const GLint dh = static_cast<GLint> (m_refractFBO->getRealHeight ());
+
+    // our copy is scene sized, the shader samples it with normalized coordinates only
+    glBindFramebuffer (GL_READ_FRAMEBUFFER, source->getFramebuffer ());
+    glBindFramebuffer (GL_DRAW_FRAMEBUFFER, m_refractFBO->getFramebuffer ());
+    glBlitFramebuffer (
+	0, 0, sw, sh, 0, 0, dw, dh, GL_COLOR_BUFFER_BIT, sw == dw && sh == dh ? GL_NEAREST : GL_LINEAR
+    );
+}
+
 void CParticle::buildRopeTrail (uint32_t& vertexIndex, uint32_t& indexOffset) {
     // sub_1402308A0, the ropetrail vertex build without a geometry shader: every particle gets one strip through
     // its position and its history, a quad per segment whether that history is filled yet or not
@@ -4475,15 +4500,7 @@ void CParticle::renderRope () {
 
     updateMatrices ();
 
-    // REFRACT: blit current scene content into the copy FBO before rendering
-    if (m_hasRefract && m_refractFBO) {
-	auto sceneFBO = getScene ().getFBO ();
-	GLint w = static_cast<GLint> (sceneFBO->getRealWidth ());
-	GLint h = static_cast<GLint> (sceneFBO->getRealHeight ());
-	glBindFramebuffer (GL_READ_FRAMEBUFFER, sceneFBO->getFramebuffer ());
-	glBindFramebuffer (GL_DRAW_FRAMEBUFFER, m_refractFBO->getFramebuffer ());
-	glBlitFramebuffer (0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
-    }
+    copyRefractSource ();
 
     glEnable (GL_DEPTH_CLAMP);
     m_pass->render ();

+ 2 - 0
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -209,6 +209,8 @@ protected:
 
     void renderSprites ();
     void renderRope ();
+    /** REFRACT: fills the _rt_FullFrameBuffer copy right before drawing, like WE's copy of the bound target */
+    void copyRefractSource () const;
     void buildRopeTrail (uint32_t& vertexIndex, uint32_t& indexOffset);
     [[nodiscard]] float ropeUVScale () const;
     void setupPass ();

+ 75 - 84
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -662,10 +662,12 @@ void CText::buildPasses () {
     nameA << "_rt_textComposite_" << this->getId () << "_a";
     nameB << "_rt_textComposite_" << this->getId () << "_b";
 
+    // the same buffer setup as image layers (text and image vtables share slot 23, sub_1401EA500): 16 bit float in HDR
+    // scene rendering
+    const TextureFormat format = this->getScene ().isHDR () ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888;
     this->m_currentMainFBO = this->m_mainFBO
-	= this->create (nameA.str (), TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0f, fboSize, fboSize);
-    this->m_currentSubFBO = this->m_subFBO
-	= this->create (nameB.str (), TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0f, fboSize, fboSize);
+	= this->create (nameA.str (), format, TextureFlags_ClampUVs, 1.0f, fboSize, fboSize);
+    this->m_currentSubFBO = this->m_subFBO = this->create (nameB.str (), format, TextureFlags_ClampUVs, 1.0f, fboSize, fboSize);
 
     // sub_140257C30: the buffer starts out as the opaque background, or as the scene behind the text with alpha 0
     // (composelayer_clearalpha), so the glyphs' translucent edges blend towards what they will be drawn over
@@ -686,6 +688,9 @@ void CText::buildPasses () {
 	clear->setViewProjectionMatrix (&m_viewProjectionMatrix);
 	clear->setModelViewProjectionMatrix (&m_compositeMatrix);
 	clear->setModelViewProjectionMatrixInverse (&m_compositeMatrixInverse);
+	// under a passthrough layer WE samples its scene copy through the layer's matrices, at the text's place in
+	// the layer buffer
+	clear->setFollowLayerTarget (true);
 	m_passes.push_back (clear);
 	base->setKeepDestination (true);
     }
@@ -882,61 +887,34 @@ void CText::render () {
     m_backgroundAlpha = alpha;
     m_backgroundColor4 = glm::vec4 (m_backgroundColor, alpha);
 
-    glm::vec3 scale = m_text.scale->value->getVec3 ();
-    glm::vec3 origin = m_text.origin->value->getVec3 ();
+    this->updateTransform ();
 
-    // texts sit under group/locator objects, same as CImage::resolveTransform
-    if (m_text.parent.has_value ()) {
-	std::vector<const Object*> ancestors;
-
-	for (const Object* current = &m_text; current->parent.has_value () && ancestors.size () < 32;) {
-	    const auto* parentObject = this->getScene ().getObject (current->parent.value ());
-
-	    if (parentObject == nullptr) {
-		break;
-	    }
-
-	    current = &parentObject->getObject ();
-	    ancestors.push_back (current);
-	}
-
-	glm::vec3 parentOrigin (0.0f);
-	glm::vec3 parentScale (1.0f);
-	float parentAngle = 0.0f;
-	const auto rotate = [] (const glm::vec2& v, float angle) {
-	    const float cosine = std::cos (angle);
-	    const float sine = std::sin (angle);
-	    return glm::vec2 (v.x * cosine - v.y * sine, v.x * sine + v.y * cosine);
-	};
+    glColorMask (true, true, true, true);
+    glDisable (GL_DEPTH_TEST);
 
-	for (auto it = ancestors.rbegin (); it != ancestors.rend (); ++it) {
-	    const Object& node = **it;
-	    glm::vec3 nodeOrigin = node.origin->value->getVec3 ();
-	    glm::vec3 nodeScale = node.groupScale->value->getVec3 ();
-	    float nodeAngle = node.groupAngles->value->getVec3 ().z;
+#if !NDEBUG
+    std::string str = "Text " + this->getObject ().name + " (" + std::to_string (this->getId ()) + ")";
+    glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
+#endif /* DEBUG */
 
-	    if (node.is<Image> ()) {
-		nodeScale = node.as<Image> ()->scale->value->getVec3 ();
-		nodeAngle = node.as<Image> ()->angles->value->getVec3 ().z;
-	    }
+    for (auto* pass : m_passes) {
+	pass->render ();
+    }
 
-	    const glm::vec2 offset
-		= rotate ({ nodeOrigin.x * parentScale.x, nodeOrigin.y * parentScale.y }, parentAngle);
-	    parentOrigin = { parentOrigin.x + offset.x, parentOrigin.y + offset.y,
-			     parentOrigin.z + nodeOrigin.z * parentScale.z };
-	    parentScale *= nodeScale;
-	    parentAngle += nodeAngle;
-	}
+#if !NDEBUG
+    glPopDebugGroup ();
+#endif /* DEBUG */
+}
 
-	const glm::vec2 offset = rotate ({ origin.x * parentScale.x, origin.y * parentScale.y }, parentAngle);
-	origin = { parentOrigin.x + offset.x, parentOrigin.y + offset.y, parentOrigin.z + origin.z * parentScale.z };
-	scale *= parentScale;
-    }
+void CText::updateTransform () {
+    const glm::vec3 scale = m_text.scale->value->getVec3 ();
 
-    // the screen anchor is applied on the matrix stack below the object's world matrix (sub_1401E8AA0), in scene units
+    // sub_140256E10: the text's world matrix is the object's full one (parents, all three angles) moved by the
+    // alignment anchor in its own scaled and rotated space. The screen anchor goes on the matrix stack in front of it
+    // (sub_1401E8AA0), in scene units
+    glm::mat4 world = getScene ().objectWorldMatrix (m_text);
     const glm::vec2 screenAnchor = this->screenAnchorOffset ();
-    origin.x += screenAnchor.x;
-    origin.y += screenAnchor.y;
+    world = glm::translate (glm::mat4 (1.0f), glm::vec3 (screenAnchor, 0.0f)) * world;
 
     // sub_140256F20: the box is centered on the object, then moved by an anchor offset from the alignment
     // (y up): left/right put that edge on the origin, top puts the first line's ascender there, bottom the last
@@ -963,30 +941,28 @@ void CText::render () {
 	anchor.y = boxCenter - (m_result.ascender - extraLines) * 0.5f;
     }
 
-    const float offsetX = anchor.x * scale.x;
-    const float offsetY = anchor.y * scale.y;
-    const float scaledHalfWidth = boxWidth * 0.5f * scale.x;
-    const float scaledHalfHeight = boxHeight * 0.5f * scale.y;
+    world = glm::translate (world, glm::vec3 (anchor, 0.0f));
 
-    // WE uses a Y-down coordinate system; match CImage's convention (CImage.cpp's
-    // updateScenePosition) of scene_h/2 - y rather than y - scene_h/2.
+    // WE's world is y up from the bottom left, this space is centered and y down; the layout below is y up too
     const float scene_w = getScene ().getCamera ().getWidth ();
     const float scene_h = getScene ().getCamera ().getHeight ();
+    const glm::mat4 flipY = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f));
+    glm::mat4 model = flipY * glm::translate (glm::mat4 (1.0f), glm::vec3 (-scene_w * 0.5f, -scene_h * 0.5f, 0.0f))
+	* world * flipY;
 
-    // Matches CImage's parallax handling (CImage.cpp:updateScreenSpacePosition) in the same
-    // pre-scale, canvas-space units as origin, so text stays visually locked to other objects at
-    // the same parallaxDepth. Added directly to gl_origin (not after the model matrix) so it
-    // isn't inadvertently multiplied by this object's own "scale".
-    glm::vec2 parallaxOffset = { 0.0f, 0.0f };
+    // Matches CImage's parallax handling (CImage.cpp:updateScreenSpacePosition): WE moves the view, so the offset
+    // lands outside the text's own rotation and scale
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
-	parallaxOffset = this->getScene ().getParallaxOffset (m_text);
+	glm::vec2 parallaxOffset = this->getScene ().getParallaxOffset (m_text);
 
 	// mirrors CImage's parallax clamp, or a text layer drifts past its edges while a same-depth
 	// CImage backing panel freezes, visibly separating the two
 	if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.clampParallaxToImageSize) {
-	    const float baseX = origin.x + offsetX - scene_w * 0.5f;
-	    const float baseY = scene_h * 0.5f - (origin.y + offsetY);
+	    const float scaledHalfWidth = boxWidth * 0.5f * scale.x;
+	    const float scaledHalfHeight = boxHeight * 0.5f * scale.y;
+	    const float baseX = model[3].x;
+	    const float baseY = model[3].y;
 	    parallaxOffset.x = clampParallaxAxis (
 		parallaxOffset.x, baseX - scaledHalfWidth, baseX + scaledHalfWidth, getScene ().getCanvasWidth ()
 	    );
@@ -994,21 +970,15 @@ void CText::render () {
 		parallaxOffset.y, baseY - scaledHalfHeight, baseY + scaledHalfHeight, getScene ().getCanvasHeight ()
 	    );
 	}
-    }
 
-    const glm::vec3 gl_origin = {
-	origin.x + offsetX - scene_w * 0.5f + parallaxOffset.x,
-	scene_h * 0.5f - (origin.y + offsetY) + parallaxOffset.y,
-	origin.z,
-    };
+	model = glm::translate (glm::mat4 (1.0f), glm::vec3 (parallaxOffset, 0.0f)) * model;
+    }
 
     const auto& camera = getScene ().getCamera ();
     // "perspective" text gets the perspective layer camera (sub_14025FAF0 -> sub_1401E5B60)
     const glm::mat4 viewProjection = camera.isOrthogonal () && m_text.perspective->value->getBool ()
 	? camera.getPerspectiveLayerViewProjection ()
 	: camera.getProjection () * camera.getLookAt ();
-    const glm::mat4 model = glm::scale (glm::translate (glm::mat4 (1.0f), gl_origin), scale);
-
     // layout space is y up, first baseline at 0; sub_140258050 centers the box: x - w/2 - min(minX, 0), y + h/2 - top
     const glm::vec3 center
 	= { -boxWidth * 0.5f - std::min (m_result.minX, 0.0f), boxHeight * 0.5f - m_result.top, 0.0f };
@@ -1030,22 +1000,43 @@ void CText::render () {
 	);
 	m_glyphBufferMatrixInverse = glm::inverse (m_glyphBufferMatrix);
     }
+}
 
-    glColorMask (true, true, true, true);
-    glDisable (GL_DEPTH_TEST);
+bool CText::hitTest (const glm::vec2& ndc) {
+    if (!m_result.valid) {
+	return false;
+    }
 
-#if !NDEBUG
-    std::string str = "Text " + this->getObject ().name + " (" + std::to_string (this->getId ()) + ")";
-    glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
-#endif /* DEBUG */
+    this->updateTransform ();
 
-    for (auto* pass : m_passes) {
-	pass->render ();
+    // sub_14019DBB0 over the text's world matrix, size +752 (sub_140258900): the layout box, plus the padding on every
+    // side when the text goes through its buffer
+    glm::vec2 half = { (m_result.maxX - m_result.minX) * 0.5f, (m_result.top - m_result.bottom) * 0.5f };
+
+    if (m_passLayout.buffered) {
+	half += this->currentPadding ();
     }
 
-#if !NDEBUG
-    glPopDebugGroup ();
-#endif /* DEBUG */
+    return Wallpapers::CScene::quadContainsPoint (m_compositeMatrix, half, ndc);
+}
+
+glm::vec2 CText::cursorLocalPosition (const glm::vec2& ndc) {
+    if (!m_result.valid) {
+	return glm::vec2 (0.0f);
+    }
+
+    this->updateTransform ();
+
+    glm::vec2 half = { (m_result.maxX - m_result.minX) * 0.5f, (m_result.top - m_result.bottom) * 0.5f };
+
+    if (m_passLayout.buffered) {
+	half += this->currentPadding ();
+    }
+
+    const auto point = Wallpapers::CScene::quadPlanePoint (m_compositeMatrix, ndc);
+
+    // the composite quad's y runs down, like the local position
+    return point.has_value () ? point.value () + half : glm::vec2 (0.0f);
 }
 
 const float& CText::getBrightness () const {

+ 9 - 0
src/WallpaperEngine/Render/Objects/CText.h

@@ -46,6 +46,13 @@ public:
     [[nodiscard]] const glm::vec3& getColor () const override;
     [[nodiscard]] const glm::vec4& getColor4 () const override;
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
+    [[nodiscard]] const Text& getText () const { return m_text; }
+    /** The composite quad's model view projection as of the last transform update */
+    [[nodiscard]] const glm::mat4& getCompositeMatrix () const { return m_compositeMatrix; }
+    /** Cursor hit test against the text's box as it is drawn, ndc is in the scene buffer's clip space */
+    [[nodiscard]] bool hitTest (const glm::vec2& ndc);
+    /** A cursor event's localPosition: from the top left of the box hitTest uses, where the cursor meets its plane */
+    [[nodiscard]] glm::vec2 cursorLocalPosition (const glm::vec2& ndc);
 
 private:
     /** The parts of the text's settings that change which passes exist */
@@ -67,6 +74,8 @@ private:
     [[nodiscard]] PassLayout currentPassLayout () const;
     [[nodiscard]] glm::vec2 currentPadding () const;
     [[nodiscard]] glm::vec2 screenAnchorOffset () const;
+    /** Scene and buffer matrices of the glyphs and the composite for this frame's transform */
+    void updateTransform ();
     void relayout (const std::string& text);
     void uploadGeometry ();
     void updateRenderVars ();

+ 151 - 50
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -254,6 +254,19 @@ std::optional<std::string> CPass::resolveUserTextureName (const std::string& pro
     return value;
 }
 
+std::shared_ptr<const TextureProvider> CPass::resolveNamedTexture (const std::string& name) const {
+    // lit materials sample every cookie spot through one alias, the scene's light cookie (sub_140190C80)
+    if (name == "_alias_lightCookie") {
+	return this->m_renderable.getScene ().getLightCookie ();
+    }
+
+    if (name.starts_with ("_rt_") || name.starts_with ("_alias_")) {
+	return this->resolveFBO (name);
+    }
+
+    return this->getContext ().resolveTexture (name, this->m_renderable.getScene ().getScene ().project);
+}
+
 std::shared_ptr<const CFBO> CPass::resolveFBO (const std::string& name) const {
     std::shared_ptr<const CFBO> fbo = this->m_fboProvider->find (name);
 
@@ -269,7 +282,13 @@ std::shared_ptr<const CFBO> CPass::resolveFBO (const std::string& name) const {
 }
 
 void CPass::setupRenderFramebuffer () const {
-    glBindFramebuffer (GL_FRAMEBUFFER, this->m_drawTo->getFramebuffer ());
+    // what would go onto the scene goes into the passthrough layer drawing its children right now
+    const auto* layerTarget = this->m_drawTo == this->m_renderable.getScene ().getFBO ()
+	? this->m_renderable.getScene ().getLayerTarget ()
+	: nullptr;
+    const auto& target = layerTarget != nullptr ? layerTarget->fbo : this->m_drawTo;
+
+    glBindFramebuffer (GL_FRAMEBUFFER, target->getFramebuffer ());
 
     // Private per-object FBOs are never cleared elsewhere, so a blending pass would otherwise
     // accumulate stale alpha across frames. The shared scene FBO must not be cleared here though,
@@ -286,7 +305,7 @@ void CPass::setupRenderFramebuffer () const {
 	glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
     }
 
-    glViewport (0, 0, this->m_drawTo->getRealWidth (), this->m_drawTo->getRealHeight ());
+    glViewport (0, 0, target->getRealWidth (), target->getRealHeight ());
 
     // the alpha source factor must be GL_ONE, GL_SRC_ALPHA squares every blended pass's alpha and compounds through
     // chained effects
@@ -299,25 +318,20 @@ void CPass::setupRenderFramebuffer () const {
 	    glEnable (GL_BLEND);
 	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE, GL_ONE, GL_ONE);
 	    break;
+	// WE's blend state builder (sub_140099F60) turns blending off for normal on every target, the scene's
+	// alpha is masked off anyway, so the pass's RGB replaces what's below regardless of its alpha
 	case BlendingMode_Normal:
-	    // "Normal" is standard alpha compositing, not a raw replace - GL_ONE/GL_ZERO discarded
-	    // the destination outright regardless of source alpha, which broke passes whose source
-	    // texture is partially transparent (e.g. unconfigured/placeholder effect textures).
-	    // Passes that always output alpha=1 render identically either way.
-	    // except into an intermediate target: blending there premultiplies RGB and darkens soft alpha edges in the
-	    // final pass
-	    if (this->m_drawTo == this->m_renderable.getScene ().getFBO ()) {
-		glEnable (GL_BLEND);
-		glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
-	    } else {
-		glDisable (GL_BLEND);
-	    }
-	    break;
 	default:
 	    glDisable (GL_BLEND);
 	    break;
     }
 
+    // sub_140099F60 with renderer state flag 0x10: alpha op MAX and all four channels written, the color blend stays
+    if (layerTarget != nullptr && layerTarget->alphaMax) {
+	glBlendEquationSeparate (GL_FUNC_ADD, GL_MAX);
+	glColorMask (true, true, true, true);
+    }
+
     switch (this->m_pass.depthtest) {
 	case DepthtestMode_Enabled:
 	    glEnable (GL_DEPTH_TEST);
@@ -685,6 +699,34 @@ void CPass::render () {
 	return;
     }
 
+    // a passthrough layer draws its children into its buffer: model = inverse (layer world), view = identity and an
+    // ortho of the layer's size (sub_1401ECB20), which comes down to remapping the scene's clip space
+    const auto* layerTarget = this->m_drawTo == this->m_renderable.getScene ().getFBO () || this->m_followLayerTarget
+	? this->m_renderable.getScene ().getLayerTarget ()
+	: nullptr;
+    const glm::mat4* modelViewProjection = this->m_modelViewProjectionMatrix;
+    const glm::mat4* modelViewProjectionInverse = this->m_modelViewProjectionMatrixInverse;
+    const glm::mat4* viewProjection = this->m_viewProjectionMatrix;
+
+    if (layerTarget != nullptr) {
+	if (modelViewProjection != nullptr) {
+	    this->m_layerModelViewProjection = layerTarget->transform * *modelViewProjection;
+	    this->m_layerModelViewProjectionInverse = glm::inverse (this->m_layerModelViewProjection);
+	    this->m_modelViewProjectionMatrix = &this->m_layerModelViewProjection;
+	    this->m_modelViewProjectionMatrixInverse = &this->m_layerModelViewProjectionInverse;
+	}
+
+	if (viewProjection != nullptr) {
+	    this->m_layerViewProjection = layerTarget->transform * *viewProjection;
+	    this->m_viewProjectionMatrix = &this->m_layerViewProjection;
+	}
+    }
+
+    // effectcomposebackground (refraction, ...) samples _rt_FullFrameBuffer where the layer is on screen
+    this->m_effectModelViewProjectionMatrix = this->m_effectModelViewProjectionOverride != nullptr
+	? this->m_effectModelViewProjectionOverride
+	: this->m_modelViewProjectionMatrix;
+
     this->setupRenderFramebuffer ();
     this->setupRenderTexture ();
     this->setupRenderUniforms ();
@@ -693,6 +735,16 @@ void CPass::render () {
     this->setupRenderAttributes ();
     this->renderGeometry ();
     this->cleanupRenderSetup ();
+
+    if (layerTarget != nullptr) {
+	this->m_modelViewProjectionMatrix = modelViewProjection;
+	this->m_modelViewProjectionMatrixInverse = modelViewProjectionInverse;
+	this->m_viewProjectionMatrix = viewProjection;
+
+	if (layerTarget->alphaMax && this->m_drawTo == this->m_renderable.getScene ().getFBO ()) {
+	    glBlendEquation (GL_FUNC_ADD);
+	}
+    }
 }
 
 void CPass::clearDestination () const {
@@ -721,6 +773,10 @@ void CPass::setPreviousInput (std::shared_ptr<const TextureProvider> input) {
     this->m_previousInput = std::move (input);
 }
 
+void CPass::setEffectModelViewProjectionMatrix (const glm::mat4* projection) {
+    this->m_effectModelViewProjectionOverride = projection;
+}
+
 void CPass::setModelViewProjectionMatrix (const glm::mat4* projection) {
     this->m_modelViewProjectionMatrix = projection;
 }
@@ -729,8 +785,12 @@ void CPass::setModelViewProjectionMatrixInverse (const glm::mat4* projection) {
     this->m_modelViewProjectionMatrixInverse = projection;
 }
 
+const glm::mat4 CPass::s_identity { 1.0f };
+
 void CPass::setModelMatrix (const glm::mat4* model) { this->m_modelMatrix = model; }
 
+void CPass::setLayerModelMatrix (const glm::mat4* model) { this->m_layerModelMatrix = model; }
+
 void CPass::setFogWorld (const bool world) {
     this->m_fogWorld = world;
     const auto& fog = this->m_renderable.getScene ().getFog ();
@@ -764,6 +824,8 @@ void CPass::setClearColor (const glm::vec4* color) { this->m_clearColor = color;
 
 void CPass::setKeepDestination (bool keep) { this->m_keepDestination = keep; }
 
+void CPass::setFollowLayerTarget (bool follow) { this->m_followLayerTarget = follow; }
+
 void CPass::setNeutralColor (bool neutral) { this->m_neutralColor = neutral; }
 
 void CPass::setPosition (GLuint position) { this->a_Position = position; }
@@ -916,6 +978,11 @@ void CPass::setupShaders () {
 	}
     }
 
+    // every pass of an orthographic scene gets SCENE_ORTHO (renderer flag 0x400, sub_1401A5C40)
+    if (this->m_renderable.getScene ().getCamera ().isOrthogonal ()) {
+	this->m_combos.insert_or_assign ("SCENE_ORTHO", 1);
+    }
+
     this->m_compiled = this->compileShaderSources (shaderName, passTextures, overrideTextures);
     this->m_shader = this->m_compiled->shader.get ();
 
@@ -929,25 +996,46 @@ void CPass::setupShaders () {
     // fog turns into FOG_DIST/FOG_HEIGHT for every pass whose FOG combo (the shader default included) is on
     // (sub_1401A5C40); that default is only known once the shader is parsed
     const auto& scene = this->m_renderable.getScene ();
-
-    if (scene.hasDistanceFog () || scene.hasHeightFog ()) {
-	const auto fogCombo = [this] () {
-	    for (const ComboMap* combos :
-		 std::initializer_list<const ComboMap*> { &this->m_override.combos, &this->m_combos }) {
-		if (const auto it = combos->find ("FOG"); it != combos->end ()) {
-		    return it->second;
-		}
+    const auto comboValue = [this] (const std::string& name) {
+	for (const ComboMap* combos :
+	     std::initializer_list<const ComboMap*> { &this->m_override.combos, &this->m_combos }) {
+	    if (const auto it = combos->find (name); it != combos->end ()) {
+		return it->second;
 	    }
-	    for (const auto* unit : { &this->m_shader->getFragment (), &this->m_shader->getVertex () }) {
-		if (const auto it = unit->getDiscoveredCombos ().find ("FOG");
-		    it != unit->getDiscoveredCombos ().end ()) {
-		    return it->second;
-		}
+	}
+	for (const auto* unit : { &this->m_shader->getFragment (), &this->m_shader->getVertex () }) {
+	    if (const auto it = unit->getDiscoveredCombos ().find (name); it != unit->getDiscoveredCombos ().end ()) {
+		return it->second;
 	    }
-	    return 0;
-	};
+	}
+	return 0;
+    };
+
+    // same for LIGHTING: the light counts of the scene's lightconfig, which the LightingV1 module is generated from.
+    // Without shadow mapping the shadow counts stay 0, like WE with its shadow setting off
+    if (comboValue ("LIGHTING") != 0) {
+	const auto& lighting = scene.getLightingV1 ();
+
+	this->m_combos.insert_or_assign ("LIGHTS_POINT", lighting.points);
+	this->m_combos.insert_or_assign ("LIGHTS_SPOT", lighting.spots);
+	this->m_combos.insert_or_assign ("LIGHTS_TUBE", lighting.tubes);
+	this->m_combos.insert_or_assign ("LIGHTS_DIRECTIONAL", lighting.directionals);
+	this->m_combos.insert_or_assign ("LIGHTS_SPOT_SHADOW_COOKIE", 0);
+	this->m_combos.insert_or_assign ("LIGHTS_SPOT_SHADOW", 0);
+	this->m_combos.insert_or_assign ("LIGHTS_SPOT_COOKIE", lighting.spotCookies);
+	this->m_combos.insert_or_assign ("LIGHTS_DIRECTIONAL_SHADOW", 0);
+	this->m_combos.insert_or_assign ("LIGHTS_POINT_SHADOW", 0);
+
+	if (lighting.spotCookies != 0) {
+	    this->m_combos.insert_or_assign ("LIGHTS_COOKIE", 1);
+	}
+
+	this->m_compiled = this->compileShaderSources (shaderName, passTextures, overrideTextures);
+	this->m_shader = this->m_compiled->shader.get ();
+    }
 
-	if (fogCombo () != 0) {
+    if (scene.hasDistanceFog () || scene.hasHeightFog ()) {
+	if (comboValue ("FOG") != 0) {
 	    if (scene.hasDistanceFog ()) {
 		this->m_combos.insert_or_assign ("FOG_DIST", 1);
 	    }
@@ -1186,9 +1274,7 @@ void CPass::setupTextureUniforms () {
     // fragment textures are checked after and override/extend the chain.
     for (const auto& [index, textureName] : this->m_shader->getVertex ().getTextures ()) {
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    // create chain entry
 	    this->m_textures[index] = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1205,9 +1291,7 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_shader->getFragment ().getTextures ()) {
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1227,9 +1311,7 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_pass.textures) {
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1261,9 +1343,7 @@ void CPass::setupTextureUniforms () {
 	const std::string& textureName = *resolvedName;
 
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1286,9 +1366,7 @@ void CPass::setupTextureUniforms () {
     // override any texture
     for (const auto& [index, textureName] : this->m_override.textures) {
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1318,9 +1396,7 @@ void CPass::setupTextureUniforms () {
 	const std::string& textureName = *resolvedName;
 
 	try {
-	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
-		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName, this->m_renderable.getScene ().getScene ().project);
+	    auto texture = this->resolveNamedTexture (textureName);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -1402,6 +1478,30 @@ void CPass::setupUniforms () {
     this->addUniform ("g_LightsPosition", UniformType::Vector3, scene.getLightsPosition (), 4);
     this->addUniform ("g_LightsColorPremultiplied", UniformType::Vector4, scene.getLightsColorPremultiplied (), 3);
     this->addUniform ("g_LightsColorRadius", UniformType::Vector4, scene.getLightsColorRadius (), 4);
+    // LightingV1, the arrays are as long as the scene's lightconfig counts
+    const auto& lighting = scene.getLightingV1 ();
+    const auto addLights = [this] (const char* name, const glm::vec4* values, const int count) {
+	if (count > 0) {
+	    this->addUniform (name, UniformType::Vector4, values, count);
+	}
+    };
+    addLights ("g_LPoint_Color", lighting.pointColor, lighting.points);
+    addLights ("g_LPoint_Origin", lighting.pointOrigin, lighting.points);
+    addLights ("g_LSpot_Color", lighting.spotColor, lighting.spots);
+    addLights ("g_LSpot_Origin", lighting.spotOrigin, lighting.spots);
+    addLights ("g_LSpot_Direction", lighting.spotDirection, lighting.spots);
+    addLights ("g_LSpot_Exponent", lighting.spotExponent, lighting.spots);
+    addLights ("g_LTube_Color", lighting.tubeColor, lighting.tubes);
+    addLights ("g_LTube_OriginA", lighting.tubeOriginA, lighting.tubes);
+    addLights ("g_LTube_OriginB", lighting.tubeOriginB, lighting.tubes);
+    addLights ("g_LDirectional_Color", lighting.directionalColor, lighting.directionals);
+    addLights ("g_LDirectional_Direction", lighting.directionalDirection, lighting.directionals);
+    if (lighting.spotCookies > 0) {
+	this->addUniform (
+	    "g_LFeature_ShadowProjection", UniformType::Matrix4, lighting.featureProjection, lighting.spotCookies
+	);
+	addLights ("g_LFeature_ShadowProjectionTransform", lighting.featureProjectionTransform, lighting.spotCookies);
+    }
     this->addUniform ("g_FogDistanceColor", &scene.getFog ().distanceColor);
     this->addUniform ("g_FogDistanceParams", &scene.getFog ().distanceParams);
     this->addUniform ("g_FogHeightColor", &scene.getFog ().heightColor);
@@ -1439,9 +1539,10 @@ void CPass::setupUniforms () {
     // add model-view-projection matrix
     this->addUniform ("g_ModelViewProjectionMatrixInverse", &this->m_modelViewProjectionMatrixInverse);
     this->addUniform ("g_ModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
-    this->addUniform ("g_EffectModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
+    this->addUniform ("g_EffectModelViewProjectionMatrix", &this->m_effectModelViewProjectionMatrix);
     this->addUniform ("g_ModelMatrix", &this->m_modelMatrix);
     this->addUniform ("g_EffectModelMatrix", &this->m_modelMatrix);
+    this->addUniform ("g_LayerModelMatrix", &this->m_layerModelMatrix);
     this->addUniform ("g_NormalModelMatrix", glm::identity<glm::mat3> ());
     this->addUniform ("g_ViewProjectionMatrix", &this->m_viewProjectionMatrix);
     this->addUniform ("g_PointerPosition", scene.getMousePosition ());

+ 18 - 0
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -47,8 +47,12 @@ public:
     void setTexCoord (GLuint texcoord);
     void setPosition (GLuint position);
     void setModelViewProjectionMatrix (const glm::mat4* projection);
+    /** where the pass's geometry ends up on screen, when that isn't its own MVP (effect passes draw into buffers) */
+    void setEffectModelViewProjectionMatrix (const glm::mat4* projection);
     void setModelViewProjectionMatrixInverse (const glm::mat4* projection);
     void setModelMatrix (const glm::mat4* model);
+    /** g_LayerModelMatrix, the layer's world matrix (WE fills it for every pass of an image, sub_1401EBF60) */
+    void setLayerModelMatrix (const glm::mat4* model);
     /** The pass draws in WE's world space (the fog composite of image layers), fog measures from WE's own eye then */
     void setFogWorld (bool world);
     void setViewProjectionMatrix (const glm::mat4* viewProjection);
@@ -62,16 +66,21 @@ public:
     void setClearColor (const glm::vec4* color);
     /** Draw over what a private destination already holds instead of clearing it first */
     void setKeepDestination (bool keep);
+    /** Take the matrices of a passthrough layer drawing its children while drawing into a private destination, for
+     *  passes that sample the scene at their on-screen position (text's clearalpha fill, sub_140257C30) */
+    void setFollowLayerTarget (bool follow);
     /** g_Color4 stays opaque white, for composites of a buffer that already carries the object's color and alpha */
     void setNeutralColor (bool neutral);
     void setBlendingMode (BlendingMode blendingmode);
     [[nodiscard]] BlendingMode getBlendingMode () const;
     [[nodiscard]] std::shared_ptr<const CFBO> resolveFBO (const std::string& name) const;
+    [[nodiscard]] std::shared_ptr<const TextureProvider> resolveNamedTexture (const std::string& name) const;
 
     [[nodiscard]] std::shared_ptr<const FBOProvider> getFBOProvider () const;
     [[nodiscard]] const CRenderable& getRenderable () const;
     [[nodiscard]] const MaterialPass& getPass () const;
     [[nodiscard]] std::optional<std::reference_wrapper<std::string>> getTarget () const;
+    [[nodiscard]] const std::shared_ptr<const CFBO>& getDestination () const { return this->m_drawTo; }
     [[nodiscard]] Render::Shaders::Shader* getShader () const;
     [[nodiscard]] GLuint getProgramID () const;
 
@@ -255,14 +264,19 @@ private:
     std::map<std::string, UniformEntry*> m_uniforms = {};
     const glm::vec4* m_clearColor = nullptr;
     bool m_keepDestination = false;
+    bool m_followLayerTarget = false;
     bool m_neutralColor = false;
     // uniforms the pass sets as material or override constants, the renderable values leave these alone
     std::set<std::string> m_constantUniforms;
     std::map<std::string, ReferenceUniformEntry*> m_referenceUniforms = {};
     BlendingMode m_blendingmode = BlendingMode_Normal;
     const glm::mat4* m_modelViewProjectionMatrix;
+    const glm::mat4* m_effectModelViewProjectionOverride = nullptr;
+    const glm::mat4* m_effectModelViewProjectionMatrix = nullptr;
     const glm::mat4* m_modelViewProjectionMatrixInverse;
     const glm::mat4* m_modelMatrix;
+    const glm::mat4* m_layerModelMatrix = &s_identity;
+    static const glm::mat4 s_identity;
     const glm::mat4* m_lightingModelMatrix;
     bool m_fogWorld = false;
     const glm::mat3* m_lightingNormalMatrix;
@@ -270,6 +284,10 @@ private:
     const glm::mat4* m_viewProjectionMatrix;
     const glm::mat4* m_effectTextureProjectionMatrix;
     const glm::mat4* m_effectTextureProjectionMatrixInverse;
+    /** The matrices of a scene pass redirected into a passthrough layer's buffer (see CScene::LayerTarget) */
+    glm::mat4 m_layerModelViewProjection = glm::mat4 (1.0f);
+    glm::mat4 m_layerModelViewProjectionInverse = glm::mat4 (1.0f);
+    glm::mat4 m_layerViewProjection = glm::mat4 (1.0f);
 
     // full xray support: 0.0/1.0 fed to the g_XrayFullReveal uniform injected by patchXrayFullRevealBypass(),
     // updated each frame from state.xray.fullReveal (see render()); m_xrayFullRevealPatched records whether

+ 236 - 18
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -8,6 +8,7 @@
 #include <mutex>
 #include <optional>
 #include <regex>
+#include <set>
 #include <stack>
 #include <string>
 #include <unordered_map>
@@ -381,15 +382,163 @@ std::string ShaderUnit::resolveRequireModule (const std::string& moduleName) con
 }
 
 std::string ShaderUnit::generateLightingV1 () const {
-    // Wallpaper Engine generates this from the scene's light sources; since light objects
-    // aren't supported yet, stub it out with no dynamic light contribution.
-    return "// begin of generated module LightingV1\n"
-	   "vec3 PerformLighting_V1(vec3 worldPos, vec3 albedo, vec3 normal, vec3 viewDir,\n"
-	   "    vec3 specularTint, vec3 baseReflectance, float roughness, float metallic)\n"
-	   "{\n"
-	   "    return vec3(0.0);\n"
-	   "}\n"
-	   "// end of generated module LightingV1\n";
+    // wallpaper64.exe 2.8.42 sub_140169140, from the pass's LIGHTS_* defines (sub_1401A5C40). WE emits nothing when the
+    // pass's LIGHTING combo is 0; its default is only parsed after the requires, so the preprocessor decides that here
+    const auto count = [this] (const std::string& name) {
+	for (const ComboMap* combos : { &this->m_overrideCombos, &this->m_combos }) {
+	    if (const auto it = combos->find (name); it != combos->end ()) {
+		return std::max (it->second, 0);
+	    }
+	}
+	return 0;
+    };
+    const int points = count ("LIGHTS_POINT");
+    const int spots = count ("LIGHTS_SPOT");
+    const int tubes = count ("LIGHTS_TUBE");
+    const int directionals = count ("LIGHTS_DIRECTIONAL");
+    const int spotShadowCookies = count ("LIGHTS_SPOT_SHADOW_COOKIE");
+    const int spotShadows = count ("LIGHTS_SPOT_SHADOW");
+    const int spotCookies = count ("LIGHTS_SPOT_COOKIE");
+    const int directionalShadows = count ("LIGHTS_DIRECTIONAL_SHADOW");
+    const int pointShadows = count ("LIGHTS_POINT_SHADOW");
+    const int features = spotShadowCookies + spotShadows + spotCookies + 3 * directionalShadows;
+    std::string out = "// begin of generated module LightingV1\n#if LIGHTING\n";
+
+    const auto declare = [&out] (const char* type, const char* name, const int size) {
+	out += std::string ("uniform ") + type + " " + name + "[" + std::to_string (size) + "];\n";
+    };
+    const auto index = [] (const char* name, const int value) {
+	return std::string ("\tconst uint ") + name + " = " + std::to_string (value) + "u;\n";
+    };
+
+    if (points) {
+	declare ("vec4", "g_LPoint_Color", points);
+	declare ("vec4", "g_LPoint_Origin", points);
+    }
+    if (spots) {
+	declare ("vec4", "g_LSpot_Color", spots);
+	declare ("vec4", "g_LSpot_Origin", spots);
+	declare ("vec4", "g_LSpot_Direction", spots);
+	declare ("vec4", "g_LSpot_Exponent", spots);
+    }
+    if (tubes) {
+	declare ("vec4", "g_LTube_Color", tubes);
+	declare ("vec4", "g_LTube_OriginA", tubes);
+	declare ("vec4", "g_LTube_OriginB", tubes);
+    }
+    if (directionals) {
+	declare ("vec4", "g_LDirectional_Color", directionals);
+	declare ("vec4", "g_LDirectional_Direction", directionals);
+    }
+    if (features) {
+	declare ("mat4", "g_LFeature_ShadowProjection", features);
+	declare ("vec4", "g_LFeature_ShadowProjectionTransform", features);
+    }
+    if (pointShadows) {
+	declare ("vec4", "g_LFeature_ShadowPointProjection", pointShadows);
+	declare ("vec4", "g_LFeature_ShadowPointProjectionTransform", pointShadows);
+    }
+
+    out += "vec3 PerformLighting_V1(vec3 worldPos, vec3 color, vec3 normal, vec3 viewVector, vec3 specularTint, vec3 "
+	   "ambient, float roughness, float metallic)\n{\n\tvec3 light = CAST3(0.0);\n";
+
+    int light = 0;
+
+    for (; light < pointShadows; light++) {
+	out += "{\n" + index ("i", light);
+	out += "\tvec3 lightDelta = g_LPoint_Origin[i].xyz - worldPos;\n"
+	       "\tvec4 projectedCoords = CalculateProjectedCoordsPoint(worldPos, g_LPoint_Origin[i].xyz, "
+	       "g_LFeature_ShadowPointProjection[i], g_LFeature_ShadowPointProjectionTransform[i]);\n"
+	       "\tfloat shadowFactor = PerformPointShadowMapping(projectedCoords);\n"
+	       "\tlight += ComputePBRLightShadow(normal, lightDelta, viewVector, color, g_LPoint_Color[i].rgb, "
+	       "g_LPoint_Color[i].w, g_LPoint_Origin[i].w, specularTint, ambient, roughness, metallic, shadowFactor);\n"
+	       "}\n";
+    }
+    for (; light < points; light++) {
+	out += "{\n" + index ("i", light);
+	out += "\tvec3 lightDelta = g_LPoint_Origin[i].xyz - worldPos;\n"
+	       "\tlight += ComputePBRLightShadow(normal, lightDelta, viewVector, color, g_LPoint_Color[i].rgb, "
+	       "g_LPoint_Color[i].w, g_LPoint_Origin[i].w, specularTint, ambient, roughness, metallic, 1.0);\n"
+	       "}\n";
+    }
+
+    // spots: shadow and cookie, cookie, shadow, plain, one running index
+    const std::string spotCookieSample = "\tvec3 projectedCoords = CalculateProjectedCoords(worldPos, "
+					 "g_LFeature_ShadowProjection[i]);\n";
+    const std::string spotShadowSample = "\tfloat shadowFactor = PerformShadowMapping(projectedCoords, "
+					 "g_LFeature_ShadowProjectionTransform[i]);\n";
+    const std::string spotCone
+	= "\tfloat spotCookie = -dot(normalize(lightDelta), g_LSpot_Direction[i].xyz);\n"
+	  "\tspotCookie = smoothstep(g_LSpot_Direction[i].w, g_LSpot_Origin[i].w, spotCookie);\n";
+    const auto spotLight = [] (const char* scale, const char* shadow) {
+	return std::string (
+		   "\tlight += ComputePBRLightShadow(normal, lightDelta, viewVector, color, g_LSpot_Color[i].rgb * "
+	       )
+	    + scale + ", g_LSpot_Color[i].w, g_LSpot_Exponent[i].x, specularTint, ambient, roughness, metallic, "
+	    + shadow + ");\n";
+    };
+    const std::string spotDelta = "\tvec3 lightDelta = g_LSpot_Origin[i].xyz - worldPos;\n";
+    const std::string cookieSample = "\tvec3 colorCookie = texSample2D(COOKIE_SAMPLER, projectedCoords.xy).rgb;\n";
+    int spot = 0;
+
+    for (int n = 0; n < spotShadowCookies; n++, spot++) {
+	out += "{\n" + index ("i", spot) + spotDelta + spotCookieSample + spotShadowSample + cookieSample
+	    + spotLight ("colorCookie", "shadowFactor") + "}\n";
+    }
+    for (int n = 0; n < spotCookies; n++, spot++) {
+	out += "{\n" + index ("i", spot) + spotDelta + spotCookieSample + cookieSample
+	    + spotLight ("colorCookie", "1.0") + "}\n";
+    }
+    for (int n = 0; n < spotShadows; n++, spot++) {
+	out += "{\n" + index ("i", spot) + spotDelta + spotCone + spotCookieSample + spotShadowSample
+	    + spotLight ("spotCookie", "shadowFactor") + "}\n";
+    }
+    for (; spot < spots; spot++) {
+	out += "{\n" + index ("i", spot) + spotDelta + spotCone + spotLight ("spotCookie", "1.0") + "}\n";
+    }
+
+    for (int tube = 0; tube < tubes; tube++) {
+	out += "{\n" + index ("i", tube);
+	out += "\tvec3 lightDelta = PointSegmentDelta(worldPos, g_LTube_OriginA[i].xyz, g_LTube_OriginB[i].xyz);\n"
+	       "\tlight += ComputePBRLightShadow(normal, lightDelta, viewVector, color, g_LTube_Color[i].rgb, "
+	       "g_LTube_Color[i].w, g_LTube_OriginA[i].w, specularTint, ambient, roughness, metallic, 1.0);\n"
+	       "}\n";
+    }
+
+    // shadowed directional lights read three cascades starting after the spots' shadow/cookie matrices; WE moves that
+    // start by one per light, not three
+    int cascade = spotShadowCookies + spotCookies + spotShadows;
+    int directional = 0;
+
+    for (; directional < directionalShadows; directional++, cascade++) {
+	out += "{\n" + index ("i", directional) + index ("p1", cascade) + index ("p2", cascade + 1)
+	    + index ("p3", cascade + 2);
+	out += "\tvec4 projectedCoords1 = CalculateProjectedCoordsCascades(worldPos, "
+	       "g_LFeature_ShadowProjection[p1]);\n"
+	       "\tvec4 projectedCoords2 = CalculateProjectedCoordsCascades(worldPos, "
+	       "g_LFeature_ShadowProjection[p2]);\n"
+	       "\tvec4 projectedCoords3 = CalculateProjectedCoordsCascades(worldPos, "
+	       "g_LFeature_ShadowProjection[p3]);\n"
+	       "\tprojectedCoords1.xyz = mix(projectedCoords1.xyz, projectedCoords2.xyz, projectedCoords1.w);\n"
+	       "\tprojectedCoords1.xyz = mix(projectedCoords1.xyz, projectedCoords3.xyz, projectedCoords2.w);\n"
+	       "\tvec4 uvTransforms = mix(g_LFeature_ShadowProjectionTransform[p1], "
+	       "g_LFeature_ShadowProjectionTransform[p2], projectedCoords1.w);\n"
+	       "\tuvTransforms = mix(uvTransforms, g_LFeature_ShadowProjectionTransform[p3], projectedCoords2.w);\n"
+	       "\tfloat shadowFactor = max(projectedCoords3.w, PerformShadowMapping(projectedCoords1.xyz, "
+	       "uvTransforms));\n"
+	       "\tlight += ComputePBRLightShadowInfinite(normal, g_LDirectional_Direction[i].xyz, viewVector, color, "
+	       "g_LDirectional_Color[i].rgb, specularTint, ambient, roughness, metallic, shadowFactor);\n"
+	       "}\n";
+    }
+    for (; directional < directionals; directional++) {
+	out += "{\n" + index ("i", directional);
+	out += "\tlight += ComputePBRLightShadowInfinite(normal, g_LDirectional_Direction[i].xyz, viewVector, color, "
+	       "g_LDirectional_Color[i].rgb, specularTint, ambient, roughness, metallic, 1.0);\n"
+	       "}\n";
+    }
+
+    out += "\treturn light;\n}\n#endif\n// end of generated module LightingV1\n";
+    return out;
 }
 
 void ShaderUnit::preprocessBalanceConditionals () {
@@ -458,11 +607,7 @@ void ShaderUnit::preprocessScalarSwizzles () {
 	}
 
 	const std::regex swizzle ("\\b" + name + "\\.[xyzwrgba](?![A-Za-z0-9_])");
-	const std::string fixed = std::regex_replace (this->m_preprocessed, swizzle, name);
-	if (fixed != this->m_preprocessed) {
-	    sLog.out ("Dropped swizzle on float ", name, " in shader ", this->m_file);
-	    this->m_preprocessed = fixed;
-	}
+	this->m_preprocessed = std::regex_replace (this->m_preprocessed, swizzle, name);
     }
 }
 
@@ -676,6 +821,49 @@ std::string ShaderUnit::applyFloatConditionCompatibility (std::string source) co
     return source;
 }
 
+std::string ShaderUnit::applyBoolArithmeticCompatibility (std::string source) const {
+    static const std::regex decl (R"(\b(float|int|uint|bool|[biu]?vec[234]|mat[234])\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
+    static const std::regex assign (R"(\b([A-Za-z_][A-Za-z0-9_]*)\s*([-+*/]?=)\s*([A-Za-z_][A-Za-z0-9_]*)\s*;)");
+
+    // every type each name is declared with (#if branches may declare it differently)
+    std::unordered_map<std::string, std::set<std::string>> types;
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), decl); it != std::sregex_iterator (); ++it) {
+	types[(*it)[2].str ()].insert ((*it)[1].str ());
+    }
+
+    const auto only = [] (const std::set<std::string>& set, std::initializer_list<const char*> allowed) {
+	return std::ranges::all_of (set, [&allowed] (const std::string& type) {
+	    return std::ranges::any_of (allowed, [&type] (const char* a) { return type == a; });
+	});
+    };
+
+    std::string result;
+    size_t last = 0;
+
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), assign); it != std::sregex_iterator (); ++it) {
+	const auto target = types.find ((*it)[1].str ());
+	const auto value = types.find ((*it)[3].str ());
+	if (target == types.end () || value == types.end () || value->second != std::set<std::string> { "bool" }
+	    || !only (target->second, { "int", "uint", "float" })) {
+	    continue;
+	}
+
+	// int converts to float implicitly, so int () fits either way
+	const std::string cast = target->second == std::set<std::string> { "uint" } ? "uint" : "int";
+	result.append (source, last, it->position (3) - last);
+	result += cast + "(" + (*it)[3].str () + ")";
+	last = it->position (3) + it->length (3);
+    }
+
+    if (last == 0) {
+	return source;
+    }
+
+    result.append (source, last, std::string::npos);
+    sLog.out ("Applied bool arithmetic compatibility in shader ", this->m_file);
+    return result;
+}
+
 std::string ShaderUnit::applyLinkedVaryingCompatibility (std::string source) const {
     if (this->m_type != GLSLContext::UnitType_Vertex || this->m_link == nullptr) {
 	return source;
@@ -881,14 +1069,38 @@ std::string ShaderUnit::applyDirectiveSemicolonCompatibility (std::string source
     return result;
 }
 
+std::string ShaderUnit::applyHlslAttributeCompatibility (std::string source) const {
+    static const std::regex attribute (
+	R"(([;{}]|^|\n)([ \t]*)\[\s*(?:loop|unroll|branch|flatten|fastopt|allow_uav_condition|forcecase|call)\s*(?:\(\s*\w*\s*\))?\s*\](?=\s*(?:for|while|do|if|switch)\b))"
+    );
+
+    std::string result = std::regex_replace (source, attribute, "$1$2");
+    if (result != source) {
+	sLog.out ("Dropped HLSL flow control attribute(s) in ", this->m_file);
+    }
+
+    return result;
+}
+
 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+([^;=]+=([^;]*);))");
+    // plain variables and function parameters, anything declared const is skipped below
+    static const std::regex variableDecl (
+	R"((\bconst\s+)?\b(?:float|int|uint|bool|[biu]?vec[234]|mat[234](?:x[234])?)\s+([A-Za-z_]\w*)\s*(?=[=;,)\[]))"
+    );
+    static const std::regex declaredName (R"(^[^=]*?([A-Za-z_]\w*)\s*(?:\[[^\]]*\]\s*)?=)");
+
     std::string names = R"(texSample2D\w*|texture\w*|g_\w+|v_\w+|wpeVar_\w+)";
     if (const std::string inputs = declaredInputNames (source); !inputs.empty ()) {
 	names += "|" + inputs;
     }
-    const std::regex nonConstant (R"(\b(?:)" + names + R"()\b)");
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), variableDecl); it != std::sregex_iterator ();
+	 ++it) {
+	if (!(*it)[1].matched) {
+	    names += "|" + (*it)[2].str ();
+	}
+    }
 
     std::string result;
     size_t count = 0;
@@ -897,11 +1109,17 @@ std::string ShaderUnit::applyNonConstantConstCompatibility (std::string source)
     for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), constLocal); it != std::sregex_iterator ();
 	 ++it) {
 	const auto& match = *it;
+	const std::regex nonConstant (R"(\b(?:)" + names + R"()\b)");
 
 	if (!std::regex_search (match[4].first, match[4].second, nonConstant)) {
 	    continue;
 	}
 
+	// later consts reading this one aren't constant either
+	if (std::smatch nameMatch; std::regex_search (match[3].first, match[3].second, nameMatch, declaredName)) {
+	    names += "|" + nameMatch[1].str ();
+	}
+
 	result.append (last, match[0].first);
 	result += match[1].str () + match[2].str () + match[3].str ();
 	last = match[0].second;
@@ -1331,15 +1549,15 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    const std::string compat = this->applyNonConstantConstCompatibility (this->applyFloatConditionCompatibility (
+    const std::string compat = this->applyNonConstantConstCompatibility (this->applyBoolArithmeticCompatibility (this->applyFloatConditionCompatibility (
 	this->applyVectorTruncationCompatibility (this->applyFragmentVaryingShadowCompatibility (
 	    this->applyFragmentTexCoordCompatibility (this->applyNarrowFragmentVaryingCompatibility (
 		this->applyLinkedVaryingCompatibility (this->applyNonConstantGlobalConstCompatibility (
-		    this->applyDirectiveSemicolonCompatibility (this->m_preprocessed)
+		    this->applyDirectiveSemicolonCompatibility (this->applyHlslAttributeCompatibility (this->m_preprocessed))
 		))
 	    ))
 	))
-    ));
+    )));
 
     {
 	std::lock_guard lock (cacheMutex);

+ 8 - 2
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -78,8 +78,11 @@ private:
     /** HLSL converts a float to bool implicitly (`cond ? a : b`, `if (cond)`), GLSL needs a real bool.
      *  Rewrites a bare float variable used as such a condition to `(cond != 0.0)`. */
     [[nodiscard]] std::string applyFloatConditionCompatibility (std::string source) const;
-    /** HLSL accepts a `const` local initialized from a texture sample, uniform or varying, GLSL only allows
-     *  constant expressions there. Drops the `const` from such locals. */
+    /** HLSL also converts a bool to a number implicitly (`barLeft *= isLeftChannel;`). A bool variable that is
+     *  the whole right-hand side of an assignment to an int/float variable gets wrapped in that type. */
+    [[nodiscard]] std::string applyBoolArithmeticCompatibility (std::string source) const;
+    /** HLSL accepts a `const` local initialized from a texture sample, uniform, varying or any other
+     *  variable, GLSL only allows constant expressions there. Drops the `const` from such locals. */
     [[nodiscard]] std::string applyNonConstantConstCompatibility (std::string source) const;
     /** Same thing for globals (`const vec2 ratio = g_Texture0Resolution.xy * g_Scale;`). GLSL needs a constant
      *  expression for any global initializer, so the global loses its initializer and gets assigned at the
@@ -88,6 +91,9 @@ private:
     /** Some workshop shaders end an #if/#elif with a `;` (`#elif AUDIOSAMPLES == 32;`) and work in WE,
      *  glslang rejects the directive. Strips the trailing `;`. */
     [[nodiscard]] std::string applyDirectiveSemicolonCompatibility (std::string source) const;
+    /** HLSL flow control attributes (`[loop]`, `[unroll(4)]`, `[branch]`...) in front of a statement pass through
+     *  WE's HLSL translation, glslang rejects them. Strips them. */
+    [[nodiscard]] std::string applyHlslAttributeCompatibility (std::string source) const;
 
     void parseComboConfiguration (const std::string& content, int defaultValue = 0);
     void parseParameterConfiguration (const std::string& type, const std::string& name, const std::string& content);

+ 353 - 49
src/WallpaperEngine/Render/Volumetrics.cpp

@@ -18,11 +18,12 @@ namespace {
 const char* kVolumeVertex = R"(
 uniform mat4 u_ViewProjection;
 uniform mat4 u_Volume;
+uniform vec3 u_MeshScale;
 layout (location = 0) in vec3 a_Position;
 out vec4 v_ScreenPos;
 
 void main () {
-    gl_Position = u_ViewProjection * u_Volume * vec4 (a_Position, 1.0);
+    gl_Position = u_ViewProjection * u_Volume * vec4 (a_Position * u_MeshScale, 1.0);
 #if FULLSCREEN
     gl_Position = vec4 (a_Position.xy, -1.0, 1.0);
 #endif
@@ -40,10 +41,12 @@ void main () {
 }
 )";
 
-// assets/shaders/volumetricsfront.frag for point lights: from the near side of the volume to the far one, the light's
-// falloff summed over a few samples. The shadow atlas has nothing in it (no shadow casters), so every sample is lit
+// assets/shaders/volumetricsfront.frag without cookies: from the near side of the volume to the far one (or the scene's
+// depth in front of it), the light's falloff summed over a few samples. The shadow atlas has nothing in it (no shadow
+// casters), so every sample is lit
 const char* kFrontFragment = R"(
 uniform sampler2D u_Back;
+uniform sampler2D u_SceneDepth;
 uniform mat4 u_InverseViewProjection;
 uniform vec2 u_BufferSize;
 uniform vec3 u_LightOrigin;
@@ -52,6 +55,11 @@ uniform float u_Intensity;
 uniform float u_Density;
 uniform float u_Exponent;
 uniform vec3 u_Color;
+uniform vec3 u_SpotForward;
+uniform float u_SpotInner;
+uniform float u_SpotOuter;
+uniform sampler2D u_Cookie;
+uniform mat4 u_LightViewProjection;
 uniform vec3 u_EyePosition;
 uniform vec3 g_FogDistanceColor;
 uniform vec4 g_FogDistanceParams;
@@ -83,14 +91,15 @@ float fogAlpha (float alpha, float viewLength, float height) {
 
 void main () {
     vec3 screenDepth = v_ScreenPos.xyz / v_ScreenPos.w;
-    float backDepth = texture (u_Back, gl_FragCoord.xy / u_BufferSize).r;
+    vec2 screenUV = gl_FragCoord.xy / u_BufferSize;
+    float backDepth = texture (u_SceneDepth, screenUV).r * 2.0 - 1.0;
+    float limitDepth = texture (u_Back, screenUV).r;
 
     if (backDepth - screenDepth.z < 0.0) {
 	discard;
     }
 
-    // nothing in the scene limits the ray, the scene buffer has no depth there
-    backDepth = min (backDepth, 1.0);
+    backDepth = min (backDepth, limitDepth);
 
     vec4 worldStart = u_InverseViewProjection * vec4 (screenDepth, 1.0);
     vec4 worldEnd = u_InverseViewProjection * vec4 (screenDepth.xy, backDepth, 1.0);
@@ -100,23 +109,42 @@ void main () {
     const float sampleCount = float (SAMPLES);
     vec3 worldStep = (worldEnd.xyz - worldStart.xyz) / (sampleCount + 1.0);
     float invRadius = 1.0 / u_Radius;
-    float maxLightScale = u_Intensity * length (worldEnd.xyz - worldStart.xyz) * invRadius * 0.5;
+    float maxLightScale = u_Intensity * length (worldEnd.xyz - worldStart.xyz) * invRadius;
+#if POINTLIGHT
+    maxLightScale *= 0.5;
+#endif
 
 #if SHADOW
     // WE's screen UV runs top down, this buffer bottom up
     worldStart.xyz += worldStep * hash12 (screenDepth.xy * 0.5 + 0.5);
 #endif
 
+#if COOKIE
+    vec3 shadowFactor = vec3 (0.0);
+#else
     float shadowFactor = 0.0;
+#endif
 
     for (int s = 0; s < SAMPLES; ++s) {
 	worldStart.xyz += worldStep;
 	vec3 lightDelta = worldStart.xyz - u_LightOrigin;
 	float sampleValue = pow (clamp (1.0 - length (lightDelta) * invRadius, 0.0, 1.0), u_Exponent);
+#if COOKIE
+	// the cookie is projected through the light's own camera, a little bigger than its frustum
+	vec4 uvs = u_LightViewProjection * vec4 (worldStart.xyz, 1.0);
+	uvs.xyz /= uvs.w;
+	vec3 cookieColor = texture (u_Cookie, uvs.xy * vec2 (0.525, -0.525) + vec2 (0.5)).rgb;
+#elif !POINTLIGHT
+	sampleValue *= smoothstep (u_SpotOuter, u_SpotInner, dot (normalize (lightDelta), u_SpotForward));
+#endif
 #if FOG_DIST || FOG_HEIGHT
 	sampleValue *= fogAlpha (sampleValue, length (u_EyePosition - worldStart.xyz), worldStart.y);
 #endif
+#if COOKIE
+	shadowFactor += sampleValue * cookieColor;
+#else
 	shadowFactor += sampleValue;
+#endif
     }
 
     shadowFactor /= sampleCount;
@@ -188,6 +216,21 @@ GLuint compile (const std::string& defines, const char* vertex, const char* frag
     return program;
 }
 
+// +1 when the triangles wind counterclockwise seen from outside the (convex) mesh, -1 when clockwise
+float outwardWinding (
+    const std::vector<glm::vec3>& vertices, const std::vector<GLushort>& indices, const glm::vec3& inside
+) {
+    float sum = 0.0f;
+
+    for (size_t i = 0; i + 2 < indices.size (); i += 3) {
+	const glm::vec3& a = vertices[indices[i]];
+	const glm::vec3 normal = glm::cross (vertices[indices[i + 1]] - a, vertices[indices[i + 2]] - a);
+	sum += glm::dot (normal, a - inside) > 0.0f ? 1.0f : -1.0f;
+    }
+
+    return sum >= 0.0f ? 1.0f : -1.0f;
+}
+
 void createTarget (auto& target, const glm::ivec2 size, const GLenum format, const GLenum type, const GLenum layout) {
     glGenTextures (1, &target.texture);
     glBindTexture (GL_TEXTURE_2D, target.texture);
@@ -214,19 +257,53 @@ Volumetrics::Volumetrics (Wallpapers::CScene& scene) :
 Volumetrics::~Volumetrics () {
     this->release ();
 
-    for (const GLuint program :
-	 { this->m_backProgram, this->m_frontProgram, this->m_frontFullscreenProgram, this->m_frontShadowProgram,
-	   this->m_frontShadowFullscreenProgram, this->m_blurProgram, this->m_compositeProgram }) {
+    for (const GLuint program : { this->m_backProgram, this->m_blurProgram, this->m_compositeProgram }) {
 	if (program != GL_NONE) {
 	    glDeleteProgram (program);
 	}
     }
 
-    if (this->m_sphereVertices != GL_NONE) {
-	glDeleteBuffers (1, &this->m_sphereVertices);
-	glDeleteBuffers (1, &this->m_sphereIndices);
-	glDeleteVertexArrays (1, &this->m_vao);
+    for (const auto& type : this->m_frontPrograms) {
+	for (const auto& shadow : type) {
+	    for (const GLuint program : shadow) {
+		if (program != GL_NONE) {
+		    glDeleteProgram (program);
+		}
+	    }
+	}
+    }
+
+    if (this->m_sceneDepth.texture != GL_NONE) {
+	destroyTarget (this->m_sceneDepth);
     }
+
+    for (auto* mesh : { &this->m_sphere, &this->m_cone, &this->m_box, &this->m_fullscreen }) {
+	if (mesh->vao != GL_NONE) {
+	    glDeleteBuffers (1, &mesh->vertices);
+	    glDeleteBuffers (1, &mesh->indices);
+	    glDeleteVertexArrays (1, &mesh->vao);
+	}
+    }
+}
+
+void Volumetrics::upload (Mesh& mesh, const std::vector<glm::vec3>& vertices, const std::vector<GLushort>& indices) {
+    glGenVertexArrays (1, &mesh.vao);
+    glBindVertexArray (mesh.vao);
+    glGenBuffers (1, &mesh.vertices);
+    glBindBuffer (GL_ARRAY_BUFFER, mesh.vertices);
+    glBufferData (
+	GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (vertices.size () * sizeof (glm::vec3)), vertices.data (),
+	GL_STATIC_DRAW
+    );
+    glGenBuffers (1, &mesh.indices);
+    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, mesh.indices);
+    glBufferData (
+	GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr> (indices.size () * sizeof (GLushort)), indices.data (),
+	GL_STATIC_DRAW
+    );
+    mesh.indexCount = static_cast<GLsizei> (indices.size ());
+    glEnableVertexAttribArray (0);
+    glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, sizeof (glm::vec3), nullptr);
 }
 
 void Volumetrics::setup () {
@@ -266,23 +343,43 @@ void Volumetrics::setup () {
 	);
     }
 
-    glGenVertexArrays (1, &this->m_vao);
-    glBindVertexArray (this->m_vao);
-    glGenBuffers (1, &this->m_sphereVertices);
-    glBindBuffer (GL_ARRAY_BUFFER, this->m_sphereVertices);
-    glBufferData (
-	GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (vertices.size () * sizeof (glm::vec3)), vertices.data (),
-	GL_STATIC_DRAW
-    );
-    glGenBuffers (1, &this->m_sphereIndices);
-    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_sphereIndices);
-    glBufferData (
-	GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr> (indices.size () * sizeof (GLushort)), indices.data (),
-	GL_STATIC_DRAW
+    this->upload (this->m_sphere, vertices, indices);
+    this->m_sphere.winding = outwardWinding (vertices, indices, glm::vec3 (0.0f));
+
+    // spot lights without a cookie: a cylinder in the light's clip space (a cone in the world) with 32 sides, depth 0
+    // at the near plane and 1 at the far one, the caps fanned out from (0.5, 0.5) (sub_140196CE0)
+    vertices = { { 0.5f, 0.5f, 0.0f }, { 0.5f, 0.5f, 1.0f } };
+    indices.clear ();
+
+    for (int side = 0; side < 32; side++) {
+	const float angle = static_cast<float> (side) * 0.03125f * 6.2831855f;
+	const float next = angle + 0.19634955f;
+	const auto base = static_cast<GLushort> (vertices.size ());
+
+	vertices.emplace_back (std::sin (angle), -std::cos (angle), 1.0f);
+	vertices.emplace_back (std::sin (next), -std::cos (next), 1.0f);
+	vertices.emplace_back (std::sin (angle), -std::cos (angle), 0.0f);
+	vertices.emplace_back (std::sin (next), -std::cos (next), 0.0f);
+	indices.insert (
+	    indices.end (), { static_cast<GLushort> (base + 2), base, static_cast<GLushort> (base + 1),
+			      static_cast<GLushort> (base + 2), static_cast<GLushort> (base + 1),
+			      static_cast<GLushort> (base + 3), 1, static_cast<GLushort> (base + 1), base, 0,
+			      static_cast<GLushort> (base + 2), static_cast<GLushort> (base + 3) }
+	);
+    }
+
+    this->upload (this->m_cone, vertices, indices);
+    this->m_cone.winding = outwardWinding (vertices, indices, glm::vec3 (0.0f, 0.0f, 0.5f));
+    // cookie spots: the whole frustum as a box in the light's clip space (sub_140196CE0)
+    vertices = { { -1.0f, -1.0f, 1.0f }, { 1.0f, -1.0f, 1.0f }, { 1.0f, 1.0f, 1.0f }, { -1.0f, 1.0f, 1.0f },
+		 { -1.0f, -1.0f, 0.0f }, { 1.0f, -1.0f, 0.0f }, { 1.0f, 1.0f, 0.0f }, { -1.0f, 1.0f, 0.0f } };
+    indices = { 1, 0, 2, 2, 0, 3, 4, 5, 6, 4, 6, 7, 3, 0, 4, 3, 4, 7,
+		1, 2, 5, 5, 2, 6, 2, 3, 6, 6, 3, 7, 0, 1, 5, 0, 5, 4 };
+    this->upload (this->m_box, vertices, indices);
+    this->m_box.winding = outwardWinding (vertices, indices, glm::vec3 (0.0f, 0.0f, 0.5f));
+    this->upload (
+	this->m_fullscreen, { { -1.0f, 1.0f, 0.0f }, { -1.0f, -3.0f, 0.0f }, { 3.0f, 1.0f, 0.0f } }, { 0, 1, 2 }
     );
-    this->m_sphereIndexCount = static_cast<GLsizei> (indices.size ());
-    glEnableVertexAttribArray (0);
-    glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, sizeof (glm::vec3), nullptr);
 
     // sample counts per quality, lights casting shadows take more and jitter the start (volumetricsfront.frag)
     const int quality = std::clamp (this->m_quality, 1, 4);
@@ -302,11 +399,31 @@ void Volumetrics::setup () {
 	= "#define SHADOW 1\n#define SAMPLES " + std::to_string (shadowed[quality - 1]) + "\n";
 
     this->m_backProgram = compile ("", kVolumeVertex, kBackFragment);
-    this->m_frontProgram = compile (fog + samples, kVolumeVertex, kFrontFragment);
-    this->m_frontFullscreenProgram = compile (fog + samples + "#define FULLSCREEN 1\n", kVolumeVertex, kFrontFragment);
-    this->m_frontShadowProgram = compile (fog + shadowSamples, kVolumeVertex, kFrontFragment);
-    this->m_frontShadowFullscreenProgram
-	= compile (fog + shadowSamples + "#define FULLSCREEN 1\n", kVolumeVertex, kFrontFragment);
+
+    // cookie lights take the shadowed sample counts too, but don't jitter
+    const std::string cookieSamples
+	= "#define COOKIE 1\n#define SAMPLES " + std::to_string (shadowed[quality - 1]) + "\n";
+
+    for (int kind = 0; kind < 3; kind++) {
+	for (int shadow = 0; shadow < 2; shadow++) {
+	    for (int fullscreen = 0; fullscreen < 2; fullscreen++) {
+		std::string defines = fog + (shadow ? shadowSamples : kind == 2 ? cookieSamples : samples);
+
+		if (shadow && kind == 2) {
+		    defines += "#define COOKIE 1\n";
+		}
+		if (kind == 0) {
+		    defines += "#define POINTLIGHT 1\n";
+		}
+		if (fullscreen) {
+		    defines += "#define FULLSCREEN 1\n";
+		}
+
+		this->m_frontPrograms[kind][shadow][fullscreen] = compile (defines, kVolumeVertex, kFrontFragment);
+	    }
+	}
+    }
+
     this->m_blurProgram = compile ("", kQuadVertex, kBlurFragment);
     this->m_compositeProgram = compile ("", kQuadVertex, kCompositeFragment);
 }
@@ -322,8 +439,7 @@ void Volumetrics::allocate (const glm::ivec2 size) {
     glBindTexture (GL_TEXTURE_2D, this->m_back.texture);
     glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
     glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
-    // shared by the back pass (farthest surface) and the light buffer (nearest one), the sphere's winding doesn't
-    // matter that way
+    // shared by the back pass and the light buffer
     glGenRenderbuffers (1, &this->m_backDepth);
     glBindRenderbuffer (GL_RENDERBUFFER, this->m_backDepth);
     glRenderbufferStorage (GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, size.x, size.y);
@@ -345,12 +461,118 @@ void Volumetrics::release () {
     this->m_backDepth = GL_NONE;
 }
 
-void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4& world) {
+glm::mat4 Volumetrics::spotViewProjection (
+    const Data::Model::Light& light, const glm::mat4& world, const bool orthographic
+) {
+    // sub_14025D420: the light looks down its x axis (view rows z, y, -x of the normalized world axes) through a
+    // right handed PerspectiveFov (sub_14009A360) of twice the outer cone, aspect 1, near 0.05 (1 in orthographic
+    // scenes), far the radius. Depth runs 0..1 like Direct3D, the cone mesh is built in that space
+    const glm::vec3 x = glm::normalize (glm::vec3 (world[0]));
+    const glm::vec3 y = glm::normalize (glm::vec3 (world[1]));
+    const glm::vec3 z = glm::normalize (glm::vec3 (world[2]));
+    const glm::vec3 origin (world[3]);
+    glm::mat4 view (1.0f);
+
+    for (int i = 0; i < 3; i++) {
+	view[i][0] = z[i];
+	view[i][1] = y[i];
+	view[i][2] = -x[i];
+    }
+
+    view[3][0] = -glm::dot (z, origin);
+    view[3][1] = -glm::dot (y, origin);
+    view[3][2] = glm::dot (x, origin);
+
+    const float near = orthographic ? 1.0f : 0.05f;
+    const float far = std::max (light.radius->value->getFloat (), near + 0.01f);
+    const float height = 1.0f / std::tan (light.outerCone->value->getFloat () * 0.017453292f);
+    const float range = far / (near - far);
+    glm::mat4 projection (0.0f);
+
+    projection[0][0] = height;
+    projection[1][1] = height;
+    projection[2][2] = range;
+    projection[2][3] = -1.0f;
+    projection[3][2] = range * near;
+
+    return projection * view;
+}
+
+void Volumetrics::copySceneDepth (const GLint framebuffer) {
+    // _rt_volumetricsBack is a depth only target the size of the output, filled from the bound target's depth before
+    // every light (render target vtable slot 8 -> CopyResource). A target of another size (a layer buffer) can't be
+    // copied, the texture keeps what it had
+    const auto fbo = this->m_scene.getFBO ();
+    const glm::ivec2 size (fbo->getRealWidth (), fbo->getRealHeight ());
+
+    if (size != this->m_sceneDepthSize) {
+	if (this->m_sceneDepth.texture != GL_NONE) {
+	    destroyTarget (this->m_sceneDepth);
+	}
+
+	this->m_sceneDepthSize = size;
+	glGenTextures (1, &this->m_sceneDepth.texture);
+	glBindTexture (GL_TEXTURE_2D, this->m_sceneDepth.texture);
+	glTexImage2D (
+	    GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, size.x, size.y, 0, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr
+	);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+	glGenFramebuffers (1, &this->m_sceneDepth.framebuffer);
+	glBindFramebuffer (GL_FRAMEBUFFER, this->m_sceneDepth.framebuffer);
+	glFramebufferTexture2D (GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, this->m_sceneDepth.texture, 0);
+	glDrawBuffer (GL_NONE);
+	glReadBuffer (GL_NONE);
+	// passes leave their own depthwrite behind, and glClear honors it
+	glDepthMask (GL_TRUE);
+	glClearDepth (1.0);
+	glClear (GL_DEPTH_BUFFER_BIT);
+	glBindFramebuffer (GL_FRAMEBUFFER, framebuffer);
+    }
+
+    GLint type = GL_NONE;
+    GLint renderbuffer = GL_NONE;
+    glGetFramebufferAttachmentParameteriv (
+	GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, &type
+    );
+
+    if (framebuffer == 0 || type != GL_RENDERBUFFER) {
+	return;
+    }
+
+    glGetFramebufferAttachmentParameteriv (
+	GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, &renderbuffer
+    );
+
+    GLint previousRenderbuffer = 0;
+    GLint width = 0;
+    GLint height = 0;
+    glGetIntegerv (GL_RENDERBUFFER_BINDING, &previousRenderbuffer);
+    glBindRenderbuffer (GL_RENDERBUFFER, renderbuffer);
+    glGetRenderbufferParameteriv (GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &width);
+    glGetRenderbufferParameteriv (GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &height);
+    glBindRenderbuffer (GL_RENDERBUFFER, previousRenderbuffer);
+
+    if (glm::ivec2 (width, height) != size) {
+	return;
+    }
+
+    glBindFramebuffer (GL_READ_FRAMEBUFFER, framebuffer);
+    glBindFramebuffer (GL_DRAW_FRAMEBUFFER, this->m_sceneDepth.framebuffer);
+    glBlitFramebuffer (0, 0, size.x, size.y, 0, 0, size.x, size.y, GL_DEPTH_BUFFER_BIT, GL_NEAREST);
+    glBindFramebuffer (GL_FRAMEBUFFER, framebuffer);
+}
+
+void Volumetrics::renderLight (
+    const Data::Model::Light& light, const glm::mat4& world, const glm::mat4& viewProjection
+) {
     if (this->m_quality <= 0) {
 	return;
     }
 
-    if (this->m_sphereVertices == GL_NONE) {
+    if (this->m_sphere.vao == GL_NONE) {
 	this->setup ();
     }
 
@@ -369,9 +591,11 @@ void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4&
     glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &previousVao);
     glGetIntegerv (GL_VIEWPORT, previousViewport);
 
+    this->copySceneDepth (previousFramebuffer);
+
+    const bool spot = light.type == Data::Model::LightType::Spot;
+    const bool cookie = spot && light.useCookie;
     const float radius = light.radius->value->getFloat ();
-    const glm::mat4 volume = world * glm::scale (glm::mat4 (1.0f), glm::vec3 (radius));
-    const glm::mat4 viewProjection = this->m_scene.getWorldViewProjection ();
     const glm::vec3 origin (world[3]);
     const auto& fog = this->m_scene.getFog ();
     const auto& camera = this->m_scene.getCamera ();
@@ -379,13 +603,51 @@ void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4&
 	? glm::vec3 (0.0f, 0.0f, -1.0f)
 	: -glm::vec3 (camera.getView ()[0][2], camera.getView ()[1][2], camera.getView ()[2][2]);
     const glm::vec3 probe = fog.eyeWorld + forward * 0.2f - origin;
-    const bool inside = radius * radius > glm::dot (probe, probe);
     const bool shadow
 	= light.castShadow && this->m_scene.getContext ().getApp ().getContext ().settings.general.shadowQuality > 0;
+    glm::mat4 volume;
+    bool inside;
+
+    const glm::mat4 lightViewProjection
+	= spot ? spotViewProjection (light, world, camera.isOrthogonal ()) : glm::mat4 (1.0f);
+
+    if (cookie) {
+	// the eye is inside the frustum's planes (sub_1401849E0: |x|, |y|, |z| <= w of the light's clip space)
+	volume = glm::inverse (lightViewProjection);
+
+	const glm::vec4 clip = lightViewProjection * glm::vec4 (fog.eyeWorld + forward * 0.1f, 1.0f);
+
+	inside = std::abs (clip.x) <= clip.w && std::abs (clip.y) <= clip.w && std::abs (clip.z) <= clip.w;
+    } else if (spot) {
+	// the cone is drawn from the light's clip space, whose inverse also gives the cone's radius at the far plane.
+	// The eye is inside when it's in front of the light, no further than the radius and within the cone there
+	volume = glm::inverse (lightViewProjection);
 
-    glBindVertexArray (this->m_vao);
+	const glm::vec3 direction = glm::normalize (glm::vec3 (world[0]));
+	const float along = glm::dot (direction, probe);
+	const float across = glm::length (probe - direction * along);
+	const glm::vec4 center = volume * glm::vec4 (0.0f, 0.0f, 1.0f, 1.0f);
+	const glm::vec4 edge = volume * glm::vec4 (0.0f, 1.0f, 1.0f, 1.0f);
+	const float farRadius = glm::length (glm::vec3 (edge) / edge.w - glm::vec3 (center) / center.w);
+
+	inside = along > 0.0f && radius >= along && along / radius * farRadius >= across;
+    } else {
+	volume = world * glm::scale (glm::mat4 (1.0f), glm::vec3 (radius));
+	inside = radius * radius > glm::dot (probe, probe);
+    }
+
+    const Mesh& mesh = cookie ? this->m_box : spot ? this->m_cone : this->m_sphere;
+    // volumetrics_back and volumetrics_front cull (cullmode normal): the back pass only draws the far side of the
+    // volume and the front pass only the near one, so where the scene's depth range clips the far side off the ray
+    // runs to the far plane. The near side faces the viewer when the mesh's outward winding, flipped by a mirroring
+    // transform, comes out clockwise on screen
+    const bool nearIsFront = mesh.winding * glm::determinant (viewProjection * volume) < 0.0f;
+
+    glBindVertexArray (mesh.vao);
     glViewport (0, 0, size.x, size.y);
-    glDisable (GL_CULL_FACE);
+    glEnable (GL_CULL_FACE);
+    glFrontFace (GL_CCW);
+    glCullFace (nearIsFront ? GL_FRONT : GL_BACK);
     glDisable (GL_BLEND);
     glEnable (GL_DEPTH_TEST);
     glDepthMask (GL_TRUE);
@@ -402,7 +664,8 @@ void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4&
 	glGetUniformLocation (this->m_backProgram, "u_ViewProjection"), 1, GL_FALSE, &viewProjection[0][0]
     );
     glUniformMatrix4fv (glGetUniformLocation (this->m_backProgram, "u_Volume"), 1, GL_FALSE, &volume[0][0]);
-    glDrawElements (GL_TRIANGLES, this->m_sphereIndexCount, GL_UNSIGNED_SHORT, nullptr);
+    glUniform3f (glGetUniformLocation (this->m_backProgram, "u_MeshScale"), 1.0f, 1.0f, 1.0f);
+    glDrawElements (GL_TRIANGLES, mesh.indexCount, GL_UNSIGNED_SHORT, nullptr);
 
     // near side, added into the light buffer. Cleared by the first light since the last composite
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_light.framebuffer);
@@ -417,22 +680,43 @@ void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4&
     }
 
     glDepthFunc (GL_LESS);
+    glCullFace (nearIsFront ? GL_BACK : GL_FRONT);
     glEnable (GL_BLEND);
     glBlendEquation (GL_FUNC_ADD);
     glBlendFunc (GL_ONE, GL_ONE);
 
-    const GLuint program = shadow ? (inside ? this->m_frontShadowFullscreenProgram : this->m_frontShadowProgram)
-				  : (inside ? this->m_frontFullscreenProgram : this->m_frontProgram);
+    const GLuint program = this->m_frontPrograms[cookie ? 2 : spot ? 1 : 0][shadow][inside];
     const glm::mat4 inverse = glm::inverse (viewProjection);
     const glm::vec3 color = light.color->value->getVec3 ();
+    const glm::vec3 spotForward (world[0]);
 
     glUseProgram (program);
+    glActiveTexture (GL_TEXTURE1);
+    glBindTexture (GL_TEXTURE_2D, this->m_sceneDepth.texture);
     glActiveTexture (GL_TEXTURE0);
     glBindTexture (GL_TEXTURE_2D, this->m_back.texture);
     glUniform1i (glGetUniformLocation (program, "u_Back"), 0);
+    glUniform1i (glGetUniformLocation (program, "u_SceneDepth"), 1);
+    glUniformMatrix4fv (
+	glGetUniformLocation (program, "u_LightViewProjection"), 1, GL_FALSE, &lightViewProjection[0][0]
+    );
+
+    if (cookie) {
+	// the light's own cookie texture (light +816, sub_14025D080)
+	const auto texture = this->m_scene.getContext ().resolveTexture (
+	    light.cookie.empty () ? "cookie/flashlight1" : light.cookie, this->m_scene.getScene ().project
+	);
+
+	glActiveTexture (GL_TEXTURE2);
+	glBindTexture (GL_TEXTURE_2D, texture->getTextureID (0));
+	glActiveTexture (GL_TEXTURE0);
+	glUniform1i (glGetUniformLocation (program, "u_Cookie"), 2);
+    }
     glUniformMatrix4fv (glGetUniformLocation (program, "u_ViewProjection"), 1, GL_FALSE, &viewProjection[0][0]);
     glUniformMatrix4fv (glGetUniformLocation (program, "u_Volume"), 1, GL_FALSE, &volume[0][0]);
     glUniformMatrix4fv (glGetUniformLocation (program, "u_InverseViewProjection"), 1, GL_FALSE, &inverse[0][0]);
+    // volumetricsfront.vert pulls the cone's sides in a little, the sphere stays as it is
+    glUniform3f (glGetUniformLocation (program, "u_MeshScale"), spot ? 0.99f : 1.0f, spot ? 0.99f : 1.0f, 1.0f);
     glUniform2f (
 	glGetUniformLocation (program, "u_BufferSize"), static_cast<float> (size.x), static_cast<float> (size.y)
     );
@@ -443,13 +727,33 @@ void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4&
     glUniform1f (glGetUniformLocation (program, "u_Density"), light.density->value->getFloat ());
     glUniform1f (glGetUniformLocation (program, "u_Exponent"), light.volumetricsExponent->value->getFloat ());
     glUniform3fv (glGetUniformLocation (program, "u_Color"), 1, &color[0]);
+    // g_RenderVar1.yz are the cosines of the cone angles, g_RenderVar3 the world matrix's x row as it is
+    glUniform1f (
+	glGetUniformLocation (program, "u_SpotInner"), std::cos (light.innerCone->value->getFloat () * 0.017453292f)
+    );
+    glUniform1f (
+	glGetUniformLocation (program, "u_SpotOuter"), std::cos (light.outerCone->value->getFloat () * 0.017453292f)
+    );
+    glUniform3fv (glGetUniformLocation (program, "u_SpotForward"), 1, &spotForward[0]);
     glUniform3fv (glGetUniformLocation (program, "u_EyePosition"), 1, &fog.eyeWorld[0]);
     glUniform3fv (glGetUniformLocation (program, "g_FogDistanceColor"), 1, &fog.distanceColor[0]);
     glUniform4fv (glGetUniformLocation (program, "g_FogDistanceParams"), 1, &fog.distanceParams[0]);
     glUniform3fv (glGetUniformLocation (program, "g_FogHeightColor"), 1, &fog.heightColor[0]);
     glUniform4fv (glGetUniformLocation (program, "g_FogHeightParams"), 1, &fog.heightParamsWorld[0]);
-    glDrawElements (GL_TRIANGLES, this->m_sphereIndexCount, GL_UNSIGNED_SHORT, nullptr);
 
+    // from inside the volume a triangle covering the screen (-1,1 / -1,-3 / 3,1) goes through volumetrics_fullscreen
+    if (inside) {
+	glDisable (GL_CULL_FACE);
+	glBindVertexArray (this->m_fullscreen.vao);
+	glDrawElements (GL_TRIANGLES, this->m_fullscreen.indexCount, GL_UNSIGNED_SHORT, nullptr);
+    } else {
+	glDrawElements (GL_TRIANGLES, mesh.indexCount, GL_UNSIGNED_SHORT, nullptr);
+    }
+
+    // passes only switch culling on and off, they expect the back faces to go
+    glCullFace (GL_BACK);
+    glFrontFace (GL_CCW);
+    glDisable (GL_CULL_FACE);
     glDisable (GL_DEPTH_TEST);
     glDepthFunc (GL_LESS);
     glDisable (GL_BLEND);
@@ -472,7 +776,7 @@ void Volumetrics::composite () {
     glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &previousVao);
     glGetIntegerv (GL_VIEWPORT, previousViewport);
 
-    glBindVertexArray (this->m_vao);
+    glBindVertexArray (this->m_fullscreen.vao);
     glDisable (GL_DEPTH_TEST);
     glDisable (GL_CULL_FACE);
     glDisable (GL_BLEND);

+ 26 - 12
src/WallpaperEngine/Render/Volumetrics.h

@@ -14,7 +14,7 @@ class Light;
 
 namespace WallpaperEngine::Render {
 /**
- * Volumetric light of LightingV1 point lights, wallpaper64.exe 2.8.42 sub_140196CE0 (one light into the light buffer)
+ * Volumetric light of LightingV1 point and spot lights, wallpaper64.exe 2.8.42 sub_140196CE0 (one light into the light buffer)
  * and sub_140198D00 (blur, then added onto the scene before the next object that isn't a light)
  */
 class Volumetrics {
@@ -22,11 +22,14 @@ public:
     explicit Volumetrics (Wallpapers::CScene& scene);
     ~Volumetrics ();
 
-    /** Raymarches the light's sphere (its world matrix scaled by the radius) into the light buffer, the first light
-     *  after a composite clears it */
-    void renderLight (const Data::Model::Light& light, const glm::mat4& world);
+    /** Raymarches the light's volume into the light buffer, the first light after a composite clears it. Point
+     *  lights use a sphere (their world matrix scaled by the radius), spot lights a cone */
+    void renderLight (const Data::Model::Light& light, const glm::mat4& world, const glm::mat4& viewProjection);
     /** Adds the light buffer onto the scene buffer if a light went into it since the last time */
     void composite ();
+    /** A spot light's view projection (sub_14025D420), Direct3D depth 0..1, what its volume and cookie use */
+    [[nodiscard]] static glm::mat4
+    spotViewProjection (const Data::Model::Light& light, const glm::mat4& world, bool orthographic);
 
 private:
     struct Target {
@@ -34,9 +37,20 @@ private:
 	GLuint framebuffer = GL_NONE;
     };
 
+    struct Mesh {
+	GLuint vao = GL_NONE;
+	GLuint vertices = GL_NONE;
+	GLuint indices = GL_NONE;
+	GLsizei indexCount = 0;
+	/** +1 counterclockwise seen from outside, -1 clockwise */
+	float winding = 1.0f;
+    };
+
     void setup ();
+    static void upload (Mesh& mesh, const std::vector<glm::vec3>& vertices, const std::vector<GLushort>& indices);
     void allocate (glm::ivec2 size);
     void release ();
+    void copySceneDepth (GLint framebuffer);
 
     Wallpapers::CScene& m_scene;
     int m_quality;
@@ -46,15 +60,15 @@ private:
     Target m_lightB;
     Target m_back;
     GLuint m_backDepth = GL_NONE;
-    GLuint m_sphereVertices = GL_NONE;
-    GLuint m_sphereIndices = GL_NONE;
-    GLsizei m_sphereIndexCount = 0;
+    Target m_sceneDepth;
+    glm::ivec2 m_sceneDepthSize {};
+    Mesh m_sphere;
+    Mesh m_cone;
+    Mesh m_box;
+    Mesh m_fullscreen;
     GLuint m_backProgram = GL_NONE;
-    GLuint m_frontProgram = GL_NONE;
-    GLuint m_frontFullscreenProgram = GL_NONE;
-    GLuint m_frontShadowProgram = GL_NONE;
-    GLuint m_frontShadowFullscreenProgram = GL_NONE;
-    GLuint m_vao = GL_NONE;
+    /** [point, spot, cookie spot][shadow][fullscreen] */
+    GLuint m_frontPrograms[3][2][2] = {};
     GLuint m_blurProgram = GL_NONE;
     GLuint m_compositeProgram = GL_NONE;
 };

+ 19 - 22
src/WallpaperEngine/Render/WallpaperState.cpp

@@ -100,10 +100,15 @@ template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScal
     projectionWidth *= m;
     projectionHeight *= m;
 
-    if (projectionWidth != viewportWidth) {
-	this->updateUs (projectionWidth, projectionHeight);
-    } else if (projectionHeight != viewportHeight) {
+    // the scaled size is truncated, so compare the ratios: comparing the truncated width with the viewport
+    // picked the wrong axis whenever it came out a pixel short (2875x2999 on 1920x1058 showed u -0.44..1.44)
+    if (m1 == m2) {
+	return;
+    }
+    if (m == m1) {
 	this->updateVs (projectionWidth, projectionHeight);
+    } else {
+	this->updateUs (projectionWidth, projectionHeight);
     }
 }
 
@@ -121,30 +126,22 @@ template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScal
     projectionWidth *= m;
     projectionHeight *= m;
 
-    if (projectionWidth != viewportWidth) {
-	this->updateUs (projectionWidth, projectionHeight);
-    } else if (projectionHeight != viewportHeight) {
+    // the scaled size is truncated, so compare the ratios: comparing the truncated width with the viewport
+    // picked the wrong axis whenever it came out a pixel short (2875x2999 on 1920x1058 showed u -0.44..1.44)
+    if (m1 == m2) {
+	return;
+    }
+    if (m == m1) {
 	this->updateVs (projectionWidth, projectionHeight);
+    } else {
+	this->updateUs (projectionWidth, projectionHeight);
     }
 }
 
 template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScaling::DefaultUVs> () {
-    this->resetUVs ();
-
-    const int viewportWidth = this->getViewportWidth ();
-    const int viewportHeight = this->getViewportHeight ();
-    const int projectionWidth = this->getProjectionWidth ();
-    const int projectionHeight = this->getProjectionHeight ();
-
-    if ((viewportHeight > viewportWidth && projectionWidth >= projectionHeight)
-	|| (viewportWidth > viewportHeight && projectionHeight > projectionWidth)) {
-	updateUs (projectionWidth, projectionHeight);
-    }
-
-    if ((viewportWidth > viewportHeight && projectionWidth >= projectionHeight)
-	|| (viewportHeight > viewportWidth && projectionHeight > projectionWidth)) {
-	updateVs (projectionWidth, projectionHeight);
-    }
+    // WE's default scaling (mode 0 in sub_140183A70) crops whichever axis overflows so the scene covers the output,
+    // the same as fill: 2977423343's 5760x2610 scene showed clamped edges letterboxed into 16:9
+    this->updateTextureUVs<TextureUVsScaling::ZoomFillUVs> ();
 }
 
 template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScaling::CenterUVs> () {

+ 568 - 55
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -21,6 +21,7 @@
 #include <chrono>
 #include <cmath>
 #include <cstdlib>
+#include <limits>
 #include <ranges>
 
 extern float g_Time;
@@ -84,6 +85,15 @@ CScene::CScene (
 	&& scene->camera.bloom.enabled->value->getBool () && scene->camera.bloom.hdr->value->getBool ();
     this->m_volumetrics = std::make_unique<Volumetrics> (*this);
 
+    // lightconfig as the scene constructor packs it with WE's shadow setting off: spotshadowcookie folds into
+    // spotcookie, the other shadow counts are dropped. Passes read these when they're built
+    const auto& lightConfig = this->getScene ().lightConfig;
+    this->m_lightingV1.points = lightConfig.point;
+    this->m_lightingV1.spots = lightConfig.spot;
+    this->m_lightingV1.tubes = lightConfig.tube;
+    this->m_lightingV1.directionals = lightConfig.directional;
+    this->m_lightingV1.spotCookies = lightConfig.spotCookie | lightConfig.spotShadowCookie;
+
     // models depth test against each other, 2D scenes can hold some too
     const bool hasModels
 	= std::ranges::any_of (scene->objects, [] (const auto& object) { return object->template is<Mesh> (); });
@@ -321,6 +331,9 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
 	renderObject->setup ();
     } catch (const std::exception& e) {
 	sLog.error ("Failed to setup object ", object.id, ": ", e.what ());
+	if (auto* scriptable = dynamic_cast<Scripting::ScriptableObject*> (renderObject); scriptable != nullptr) {
+	    this->getScriptEngine ().dropObjectScripts (*scriptable);
+	}
 	delete renderObject;
 	renderObject = nullptr;
     }
@@ -404,6 +417,7 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 }
 
 void CScene::renderFrameSteps (const glm::ivec4& viewport) {
+    this->m_outputSize = { viewport.z, viewport.w };
     timeStep ("updateMouse", [&] { this->updateMouse (viewport); });
 
     // after the tick, so a layer a script moves this frame (e.g. onto input.cursorWorldPosition) is hit tested where it
@@ -414,6 +428,7 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     this->updateParallax ();
     timeStep ("cursor events", [&] { this->dispatchCursorEvents (); });
     this->updateLights ();
+    this->updateLightingV1 ();
 
     // only image objects need their texture (e.g. video/gif frame) refreshed before drawing
     for (const auto& cur : this->m_objectsByRenderOrder) {
@@ -449,47 +464,26 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     glClearColor (clearColor.r, clearColor.g, clearColor.b, 1.0f);
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
-    for (const auto& cur : this->m_objectsByRenderOrder) {
-	const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
-	if (debug.objectFilter.has_value () && cur->getId () != debug.objectFilter.value ()) {
-	    continue;
-	}
-	if (std::ranges::find (debug.skipObjects, cur->getId ()) != debug.skipObjects.end ()) {
-	    continue;
-	}
-
-	const auto visibility = this->getContext ().getApp ().getContext ().resolveObjectVisibility (
-	    cur->getId (), cur->getObject ().name
-	);
-	if (visibility.has_value () && !visibility.value ()) {
-	    continue;
-	}
-
-	if (this->isHiddenByAncestor (*cur)) {
-	    continue;
-	}
+    // transparentsorting only does something in 3D scenes (render flags & 0x1008 == 0x1000, sub_14018AAC0): the rest
+    // goes first in list order, then the transparent objects back to front
+    if (this->getCamera ().isPerspective () && this->getScene ().transparentSorting->value->getBool ()) {
+	std::vector<CObject*> transparent;
 
-	// lights draw their volumes into the light buffer, which goes onto the scene before the next other object
-	if (cur->is<Objects::CLight> ()) {
-	    const auto& light = cur->as<Objects::CLight> ()->getLight ();
-
-	    if (light.type == LightType::Point && light.castVolumetrics && light.visible->value->getBool ()
-		&& (!visibility.has_value () || visibility.value ())) {
-		timeStep ("volumetrics " + light.name, [&] {
-		    this->m_volumetrics->renderLight (light, this->objectWorldMatrix (light));
-		});
+	for (auto* cur : std::vector (this->m_objectsByRenderOrder)) {
+	    if (this->isTransparentSorted (*cur)) {
+		transparent.push_back (cur);
+	    } else {
+		this->renderSceneObject (cur);
 	    }
-
-	    continue;
 	}
 
-	this->m_volumetrics->composite ();
-
-	if (cur == this->m_bloomObject) {
-	    this->updateMipMappedFrameBuffer ();
+	for (auto* cur : this->sortedByDepth (std::move (transparent))) {
+	    this->renderSceneObject (cur);
+	}
+    } else {
+	for (const auto& cur : this->m_objectsByRenderOrder) {
+	    this->renderSceneObject (cur);
 	}
-
-	timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
     }
 
     this->m_volumetrics->composite ();
@@ -507,6 +501,45 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     }
 }
 
+void CScene::renderSceneObject (CObject* cur) {
+    const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
+    if (debug.objectFilter.has_value () && cur->getId () != debug.objectFilter.value ()) {
+	return;
+    }
+    if (std::ranges::find (debug.skipObjects, cur->getId ()) != debug.skipObjects.end ()) {
+	return;
+    }
+
+    const auto visibility
+	= this->getContext ().getApp ().getContext ().resolveObjectVisibility (cur->getId (), cur->getObject ().name);
+    if (visibility.has_value () && !visibility.value ()) {
+	return;
+    }
+
+    if (this->isHiddenByAncestor (*cur)) {
+	return;
+    }
+
+    // a passthrough layer above it draws it into its own buffer (object flag 2, skipped by sub_14018AAC0)
+    if (this->isDrawnByPassthroughLayer (*cur)) {
+	return;
+    }
+
+    // lights draw their volumes into the light buffer, which goes onto the scene before the next other object
+    if (cur->is<Objects::CLight> ()) {
+	this->renderLightVolume (*cur->as<Objects::CLight> (), this->getWorldViewProjection ());
+	return;
+    }
+
+    this->m_volumetrics->composite ();
+
+    if (cur == this->m_bloomObject) {
+	this->updateMipMappedFrameBuffer ();
+    }
+
+    timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
+}
+
 namespace {
 GLuint buildProgram (const std::string& defines, const char* fragmentBody) {
     static const char* vertex = "#version 330\n"
@@ -907,9 +940,10 @@ void CScene::updateParallax () {
 	    this->m_parallaxBias = positionBias * amount * influence;
 
 	    // same easing as the real engine: no delay snaps to the mouse, otherwise a fast exponential follow
-	    // a camera object's eye moves the parallax camera too, before the smoothing (sub_1401891A0)
+	    // a camera object's eye moves the parallax camera too, before the smoothing (sub_1401891A0). All of it
+	    // is y up like WE's world: the cursor's y is flipped, the eye isn't
 	    const glm::vec2 eye = { this->m_cameraEye.x / static_cast<float> (this->getWidth ()),
-				    -this->m_cameraEye.y / static_cast<float> (this->getHeight ()) };
+				    this->m_cameraEye.y / static_cast<float> (this->getHeight ()) };
 	    const glm::vec2 target = (this->m_mousePosition - glm::vec2 (0.5f, 0.5f)) * influence + eye;
 	    if (delay <= 0.0f) {
 		this->m_cameraParallax = target;
@@ -1177,30 +1211,67 @@ void CScene::dispatchCursorEvents () {
     this->m_cursorLastScenePosition = scenePosition;
 
     auto& engine = this->getScriptEngine ();
-    // handlers are free to create layers, which would move things around under a live iteration
-    const auto objects = this->m_objectsByRenderOrder;
+    // handlers are free to create layers, which would move things around under a live iteration. 3D scenes walk a
+    // copy sorted by depth like the transparent pass (sub_1401865C0), 2D ones the creation order
+    const auto objects = this->getCamera ().isPerspective () ? this->sortedByDepth (this->m_objectsByRenderOrder)
+								: this->m_objectsByRenderOrder;
     // WE (sub_140189E10) checks for a drag before the pass: while the button is held on something pressed,
     // only the pressed objects hear about it, and only through cursorMove
     const bool dragging = down && !this->m_cursorPressed.empty ();
+    // scene buffer clip space, what the objects' matrices project to; the buffer is stored upside down, clip y grows
+    // towards the bottom of the screen like the cursor's
+    const glm::vec2 ndc = this->m_mousePositionNormalized * 2.0f - 1.0f;
 
-    // topmost first, like WE. Hidden objects are hit tested and get events too, they just can't stop propagation
+    // topmost first, like WE. Hidden objects are hit tested and get events too, they just can't stop propagation.
+    // Images and text (object types 1 and 4) test their quad, models (type 5) their bounds
     for (auto it = objects.rbegin (); it != objects.rend (); ++it) {
 	auto* cur = *it;
 
-	if (!cur->getObject ().solid->value->getBool () || !cur->is<Objects::CImage> ()) {
+	if (!cur->getObject ().solid->value->getBool ()) {
 	    continue;
 	}
 
-	auto* image = cur->as<Objects::CImage> ();
-	const int id = image->getImage ().id;
-	const bool handlers = engine.hasCursorHandlers (*image);
-
-	image->refreshScenePosition ();
+	Scripting::ScriptableObject* scriptable = nullptr;
+	bool hit = false;
+	bool visible = false;
+	// the event's localPosition is whatever the object's hit test left behind (sub_14019DBB0 / sub_140185520),
+	// quads fill it in off the quad too
+	glm::vec3 local {};
+
+	if (cur->is<Objects::CImage> ()) {
+	    auto* image = cur->as<Objects::CImage> ();
+
+	    scriptable = image;
+	    hit = image->hitTest (ndc);
+	    // fullscreen layers get the cursor in window pixels: WE stores ScreenToClient / client size and multiplies
+	    // it back by the client size (sub_140110630, sub_14017F1B0)
+	    local = image->getImage ().model->fullscreen
+		? glm::vec3 (this->m_mousePositionViewport, 0.0f)
+		: glm::vec3 (image->cursorLocalPosition (ndc), 0.0f);
+	    visible = image->getImage ().visible->value->getBool ();
+	} else if (cur->is<Objects::CText> ()) {
+	    auto* text = cur->as<Objects::CText> ();
+
+	    scriptable = text;
+	    hit = text->hitTest (ndc);
+	    local = glm::vec3 (text->cursorLocalPosition (ndc), 0.0f);
+	    visible = text->getText ().visible->value->getBool ();
+	} else if (cur->is<Objects::CMesh> ()) {
+	    auto* mesh = cur->as<Objects::CMesh> ();
+
+	    scriptable = mesh;
+	    hit = mesh->hitTest (ndc);
+	    local = mesh->cursorLocalPosition (ndc);
+	    visible = mesh->getMesh ().groupVisible->value->getBool ();
+	} else {
+	    continue;
+	}
 
-	const glm::vec2 local = scenePosition - image->getSceneCenter ();
+	const int id = cur->getId ();
+	const bool handlers = engine.hasCursorHandlers (*scriptable);
 	const auto dispatch = [&] (const char* event) {
 	    if (handlers) {
-		engine.dispatchCursorEvent (event, *image, scenePosition, local);
+		engine.dispatchCursorEvent (event, *scriptable, scenePosition, local);
 	    }
 	};
 
@@ -1212,7 +1283,7 @@ void CScene::dispatchCursorEvents () {
 	    continue;
 	}
 
-	if (!image->containsScenePoint (scenePosition)) {
+	if (!hit) {
 	    if (released && this->m_cursorPressed.contains (id)) {
 		dispatch ("cursorUp");
 	    }
@@ -1248,9 +1319,9 @@ void CScene::dispatchCursorEvents () {
 	}
 
 	const auto visibility
-	    = this->getContext ().getApp ().getContext ().resolveObjectVisibility (id, image->getImage ().name);
+	    = this->getContext ().getApp ().getContext ().resolveObjectVisibility (id, cur->getObject ().name);
 
-	if (visibility.value_or (image->getImage ().visible->value->getBool ()) && !this->isHiddenByAncestor (*cur)) {
+	if (visibility.value_or (visible) && !this->isHiddenByAncestor (*cur)) {
 	    break;
 	}
     }
@@ -1264,6 +1335,7 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
     const glm::dvec2 position = this->getContext ().getInputContext ().getMouseInput ().position ();
 
     this->m_mousePositionLast = this->m_mousePosition;
+    this->m_mousePositionViewport = glm::vec2 (position.x - viewport.x, position.y - viewport.y);
 
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
 
@@ -1416,11 +1488,11 @@ glm::vec2 CScene::getParallaxOffset (const Object& object) const {
     glm::vec2 shift = (*depth + amount) * this->m_parallaxBias * width;
 
     if (!this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
-	// real engine: (origin - cameraPosition) * amount * depth, camera sitting at the mouse-driven point of the
-	// scene. Origins are y-up and this space is y-down, hence the flipped Y terms
+	// real engine: (origin - cameraPosition) * amount * depth in its y up world (sub_14018AAC0), the camera
+	// sitting at the mouse-driven point of the scene. This space is y down, hence the negated y
 	const glm::vec3 origin = anchor->origin->value->getVec3 ();
 	shift.x += (origin.x - width * 0.5f - this->m_cameraParallax.x * width) * amount * depth->x;
-	shift.y -= (origin.y - height * 0.5f + this->m_cameraParallax.y * height) * amount * depth->y;
+	shift.y -= (origin.y - height * 0.5f - this->m_cameraParallax.y * height) * amount * depth->y;
     }
 
     return shift;
@@ -1493,6 +1565,447 @@ void CScene::updateLights () {
     std::ranges::copy (positions, this->m_lightsPosition);
 }
 
+std::vector<const Light*> CScene::sortedLightingV1Lights () const {
+    // sub_140186990 orders the visible lights by type, then shadow/cookie flags (set ones first), then by their own
+    // origin along the camera's forward axis
+    const auto& camera = this->getCamera ();
+    const glm::vec3 forward = camera.isOrthogonal ()
+	? glm::vec3 (0.0f, 0.0f, -1.0f)
+	: -glm::vec3 (camera.getView ()[0][2], camera.getView ()[1][2], camera.getView ()[2][2]);
+    struct Entry {
+	int type;
+	int flags;
+	float depth;
+	const Light* light;
+    };
+    std::vector<Entry> entries;
+
+    for (const auto* object : this->m_objects | std::views::values) {
+	if (!object->is<Objects::CLight> ()) {
+	    continue;
+	}
+
+	const auto& data = object->as<Objects::CLight> ()->getLight ();
+
+	if (data.type == LightType::Legacy) {
+	    continue;
+	}
+
+	const auto override = this->getContext ().getApp ().getContext ().resolveObjectVisibility (data.id, data.name);
+	const bool visible = override.has_value () ? override.value () : data.visible->value->getBool ();
+
+	if (!visible || this->isHiddenByAncestor (*object)) {
+	    continue;
+	}
+
+	entries.push_back (
+	    { static_cast<int> (data.type), (data.castShadow ? 1 : 0) | (data.useCookie ? 2 : 0),
+	      glm::dot (data.origin->value->getVec3 (), forward), &data }
+	);
+    }
+
+    std::ranges::stable_sort (entries, [] (const Entry& a, const Entry& b) {
+	if (a.type != b.type) {
+	    return a.type < b.type;
+	}
+	if (a.flags != b.flags) {
+	    return a.flags > b.flags;
+	}
+	return a.depth < b.depth;
+    });
+
+    std::vector<const Light*> lights;
+
+    for (const auto& entry : entries) {
+	lights.push_back (entry.light);
+    }
+
+    return lights;
+}
+
+std::shared_ptr<const TextureProvider> CScene::getLightCookie () const {
+    // one cookie texture for every lit material: the last cookie spot the uniform packing takes (sub_140190C80),
+    // its "cookie" or cookie/flashlight1 (sub_14025D080). Passes are built while the scene's objects are still being
+    // created, so this goes by the scene data: visible spots in the packing's order (type is equal, cookie spots
+    // sort before the others, then by depth)
+    const auto& camera = this->getCamera ();
+    const glm::vec3 forward = camera.isOrthogonal ()
+	? glm::vec3 (0.0f, 0.0f, -1.0f)
+	: -glm::vec3 (camera.getView ()[0][2], camera.getView ()[1][2], camera.getView ()[2][2]);
+    std::vector<const Light*> spots;
+
+    for (const auto& object : this->getScene ().objects) {
+	if (!object->is<Light> ()) {
+	    continue;
+	}
+
+	const auto* light = object->as<Light> ();
+	const auto override
+	    = this->getContext ().getApp ().getContext ().resolveObjectVisibility (light->id, light->name);
+
+	if (light->type == LightType::Spot
+	    && (override.has_value () ? override.value () : light->visible->value->getBool ())) {
+	    spots.push_back (light);
+	}
+    }
+
+    std::ranges::stable_sort (spots, [&forward] (const Light* a, const Light* b) {
+	const int flagsA = (a->castShadow ? 1 : 0) | (a->useCookie ? 2 : 0);
+	const int flagsB = (b->castShadow ? 1 : 0) | (b->useCookie ? 2 : 0);
+
+	if (flagsA != flagsB) {
+	    return flagsA > flagsB;
+	}
+	return glm::dot (a->origin->value->getVec3 (), forward) < glm::dot (b->origin->value->getVec3 (), forward);
+    });
+
+    const Light* last = nullptr;
+
+    for (int i = 0; i < static_cast<int> (spots.size ()) && i < this->m_lightingV1.spots; i++) {
+	if (spots[i]->useCookie) {
+	    last = spots[i];
+	}
+    }
+
+    // without a cookie spot nothing samples it
+    return this->getContext ().resolveTexture (
+	last == nullptr || last->cookie.empty () ? "cookie/flashlight1" : last->cookie, this->getScene ().project
+    );
+}
+
+void CScene::updateLightingV1 () {
+    auto& lighting = this->m_lightingV1;
+    const LightingV1 counts = lighting;
+
+    lighting = LightingV1 {};
+    lighting.points = counts.points;
+    lighting.spots = counts.spots;
+    lighting.tubes = counts.tubes;
+    lighting.directionals = counts.directionals;
+    lighting.spotCookies = counts.spotCookies;
+
+    if (lighting.points + lighting.spots + lighting.tubes + lighting.directionals == 0) {
+	return;
+    }
+
+    const auto& camera = this->getCamera ();
+    const std::vector<const Light*> lights = this->sortedLightingV1Lights ();
+
+    // one slot per light until its type's count runs out; cookie spots come first, the others after them
+    int point = 0;
+    int cookieSpot = 0;
+    int plainSpot = lighting.spotCookies;
+    int spotsLeft = lighting.spots;
+    int tube = 0;
+    int directional = 0;
+
+    for (const Light* entry : lights) {
+	const Light& light = *entry;
+	const glm::mat4 world = this->objectWorldMatrix (light);
+	const glm::vec3 color = light.color->value->getVec3 () * light.intensity->value->getFloat ();
+	const float radius = light.radius->value->getFloat ();
+	const float exponent = light.exponent->value->getFloat ();
+	const glm::vec3 origin (world[3]);
+
+	switch (light.type) {
+	    case LightType::Point:
+		if (point < lighting.points) {
+		    lighting.pointColor[point] = glm::vec4 (color, radius);
+		    lighting.pointOrigin[point] = glm::vec4 (origin, exponent);
+		    point++;
+		}
+		break;
+	    case LightType::Spot: {
+		if (spotsLeft == 0) {
+		    break;
+		}
+
+		spotsLeft--;
+
+		int& slot = light.useCookie ? cookieSpot : plainSpot;
+
+		if (slot >= lighting.spots) {
+		    break;
+		}
+
+		// cosines of the cone angles in the .w, the direction is the world matrix's x column as it is
+		lighting.spotColor[slot] = glm::vec4 (color, radius);
+		lighting.spotOrigin[slot]
+		    = glm::vec4 (origin, std::cos (light.innerCone->value->getFloat () * 0.017453292f));
+		lighting.spotDirection[slot]
+		    = glm::vec4 (glm::vec3 (world[0]), std::cos (light.outerCone->value->getFloat () * 0.017453292f));
+		lighting.spotExponent[slot] = glm::vec4 (exponent, 0.0f, 0.0f, 0.0f);
+
+		if (light.useCookie && slot < 3) {
+		    lighting.featureProjection[slot]
+			= Volumetrics::spotViewProjection (light, world, camera.isOrthogonal ());
+		}
+
+		slot++;
+		break;
+	    }
+	    case LightType::Tube:
+		if (tube < lighting.tubes) {
+		    lighting.tubeColor[tube] = glm::vec4 (color, radius);
+		    lighting.tubeOriginA[tube] = glm::vec4 (origin, exponent);
+		    lighting.tubeOriginB[tube]
+			= glm::vec4 (glm::vec3 (world * glm::vec4 (light.controlPoint->value->getVec3 (), 1.0f)), 0.0f);
+		    tube++;
+		}
+		break;
+	    case LightType::Directional:
+		if (directional < lighting.directionals) {
+		    lighting.directionalColor[directional] = glm::vec4 (color, 1.0f);
+		    lighting.directionalDirection[directional] = glm::vec4 (-glm::vec3 (world[0]), 0.0f);
+		    directional++;
+		}
+		break;
+	    default:
+		break;
+	}
+    }
+}
+
+const CScene::LayerTarget* CScene::getLayerTarget () const {
+    return this->m_layerTargets.empty () ? nullptr : &this->m_layerTargets.back ();
+}
+
+namespace {
+bool isPassthroughLayer (const CObject& object) {
+    return object.is<Objects::CImage> () && object.as<Objects::CImage> ()->getImage ().model->passthrough;
+}
+} // namespace
+
+bool CScene::isDrawnByPassthroughLayer (const CObject& object) const {
+    const Object* current = &object.getObject ();
+
+    // set when the parent has flag 2 or 4 (sub_1401DD9A0), so any passthrough ancestor
+    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
+	const auto* parent = this->getObject (current->parent.value ());
+
+	if (parent == nullptr) {
+	    return false;
+	}
+
+	if (isPassthroughLayer (*parent)) {
+	    return true;
+	}
+
+	current = &parent->getObject ();
+    }
+
+    return false;
+}
+
+std::vector<CObject*> CScene::childrenOf (int id) const {
+    std::vector<CObject*> children;
+
+    for (auto* object : this->m_objectsByRenderOrder) {
+	if (object->getObject ().parent == id) {
+	    children.push_back (object);
+	}
+    }
+
+    return children;
+}
+
+void CScene::renderPassthroughChildren (int layerId, const LayerTarget& target) {
+    this->m_layerTargets.push_back (target);
+
+    // direct children only when visible (slot 13), their subtrees unless they are passthrough layers themselves,
+    // which draw their own (sub_1401ECB20)
+    for (auto* child : this->childrenOf (layerId)) {
+	this->renderPassthroughChild (child);
+
+	if (!isPassthroughLayer (*child)) {
+	    this->renderPassthroughSubtree (child->getId (), 1);
+	}
+    }
+
+    this->m_layerTargets.pop_back ();
+}
+
+void CScene::renderPassthroughSubtree (int parentId, int depth) {
+    if (depth >= 64) {
+	return;
+    }
+
+    for (auto* child : this->childrenOf (parentId)) {
+	this->renderPassthroughChild (child);
+
+	if (!isPassthroughLayer (*child)) {
+	    this->renderPassthroughSubtree (child->getId (), depth + 1);
+	}
+    }
+}
+
+void CScene::renderPassthroughChild (CObject* object) {
+    const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
+    if (debug.objectFilter.has_value () && object->getId () != debug.objectFilter.value ()) {
+	return;
+    }
+    if (std::ranges::find (debug.skipObjects, object->getId ()) != debug.skipObjects.end ()) {
+	return;
+    }
+
+    const auto visibility = this->getContext ().getApp ().getContext ().resolveObjectVisibility (
+	object->getId (), object->getObject ().name
+    );
+    if ((visibility.has_value () && !visibility.value ()) || this->isHiddenByAncestor (*object)) {
+	return;
+    }
+
+    // lights go into the shared light buffer (sub_140196CE0 called from sub_1401ECB20), the main loop adds it onto
+    // the scene before the next top level object that isn't a light. sub_1401ECB20 swaps the matrices without
+    // marking the renderer dirty, so a light that comes before any other drawn child still sees the scene's view
+    // projection (checked against live WE)
+    if (object->is<Objects::CLight> ()) {
+	const auto& target = this->m_layerTargets.back ();
+
+	this->renderLightVolume (
+	    *object->as<Objects::CLight> (),
+	    target.viewProjectionApplied ? target.viewProjection : this->getWorldViewProjection ()
+	);
+	return;
+    }
+
+    timeStep ("render " + object->getObject ().name, [&] { object->render (); });
+    // nested layers push and pop their own targets while rendering, look it up again
+    this->m_layerTargets.back ().viewProjectionApplied = true;
+}
+
+void CScene::renderLightVolume (const Objects::CLight& object, const glm::mat4& viewProjection) {
+    const auto& light = object.getLight ();
+
+    // WE sends every light through the same code, but only point and spot lights get a volume matrix
+    // (sub_14025D420). Tubes and directional lights would draw the cone through a matrix nothing sets, left out
+    if ((light.type != LightType::Point && light.type != LightType::Spot) || !light.castVolumetrics
+	|| !light.visible->value->getBool ()) {
+	return;
+    }
+
+    timeStep ("volumetrics " + light.name, [&] {
+	this->m_volumetrics->renderLight (light, this->objectWorldMatrix (light), viewProjection);
+    });
+}
+
+bool CScene::isTransparentSorted (const CObject& object) {
+    const auto cached = this->m_transparentSorted.find (&object);
+
+    if (cached != this->m_transparentSorted.end ()) {
+	return cached->second;
+    }
+
+    bool sorted = false;
+
+    // particles, lights and text always, images by their material's blending or as passthrough layers without
+    // copybackground (sub_1401FAC50), models never. WE's bloom is no scene object
+    if (object.is<Objects::CParticle> () || object.is<Objects::CLight> () || object.is<Objects::CText> ()) {
+	sorted = true;
+    } else if (object.is<Objects::CImage> () && &object != this->m_bloomObject) {
+	const auto& image = object.as<Objects::CImage> ()->getImage ();
+	const auto& passes = image.model->material->passes;
+	const BlendingMode blending = passes.empty () ? BlendingMode_Normal : passes.front ()->blending;
+
+	sorted = blending == BlendingMode_Translucent || blending == BlendingMode_Additive
+	    || (image.model->passthrough && !image.copyBackground->value->getBool ());
+    }
+
+    this->m_transparentSorted.emplace (&object, sorted);
+    return sorted;
+}
+
+std::vector<CObject*> CScene::sortedByDepth (std::vector<CObject*> objects) const {
+    // key = origin . view direction, the renderer keeps -row 2 of the view matrix for it (sub_14017FA70); fullscreen
+    // and project layers (object flag 0x200) get -inf. Stable, largest key first
+    const glm::mat4& view = this->getCamera ().getView ();
+    const glm::vec3 forward = -glm::vec3 (view[0][2], view[1][2], view[2][2]);
+    std::vector<std::pair<float, CObject*>> keyed;
+
+    keyed.reserve (objects.size ());
+
+    for (auto* object : objects) {
+	float key = glm::dot (object->getObject ().origin->value->getVec3 (), forward);
+
+	if (object->is<Objects::CImage> ()) {
+	    const auto& model = *object->as<Objects::CImage> ()->getImage ().model;
+
+	    if (model.fullscreen || model.projectlayer) {
+		key = -std::numeric_limits<float>::infinity ();
+	    }
+	}
+
+	keyed.emplace_back (key, object);
+    }
+
+    std::ranges::stable_sort (keyed, [] (const auto& a, const auto& b) { return a.first > b.first; });
+
+    for (size_t i = 0; i < keyed.size (); i++) {
+	objects[i] = keyed[i].second;
+    }
+
+    return objects;
+}
+
+bool CScene::quadContainsPoint (const glm::mat4& mvp, const glm::vec2& half, const glm::vec2& ndc) {
+    const glm::vec2 corners[] = { { -half.x, -half.y }, { half.x, -half.y }, { half.x, half.y }, { -half.x, half.y } };
+    glm::vec2 projected[4];
+
+    for (int i = 0; i < 4; i++) {
+	const glm::vec4 clip = mvp * glm::vec4 (corners[i], 0.0f, 1.0f);
+
+	if (clip.w <= 0.0f) {
+	    return false;
+	}
+
+	projected[i] = glm::vec2 (clip) / clip.w;
+    }
+
+    bool positive = false;
+    bool negative = false;
+
+    for (int i = 0; i < 4; i++) {
+	const glm::vec2 edge = projected[(i + 1) % 4] - projected[i];
+	const glm::vec2 toPoint = ndc - projected[i];
+	const float cross = edge.x * toPoint.y - edge.y * toPoint.x;
+
+	positive |= cross > 0.0f;
+	negative |= cross < 0.0f;
+    }
+
+    // a quad seen edge on has no area and nothing to hit
+    const glm::vec2 diagonalA = projected[2] - projected[0];
+    const glm::vec2 diagonalB = projected[3] - projected[1];
+
+    return !(positive && negative) && (diagonalA.x * diagonalB.y - diagonalA.y * diagonalB.x) != 0.0f;
+}
+
+std::optional<glm::vec2> CScene::quadPlanePoint (const glm::mat4& mvp, const glm::vec2& ndc) {
+    // clip = x * col0 + y * col1 + col3, and the point lands on ndc when clip.xy = ndc * clip.w
+    const glm::vec4& ax = mvp[0];
+    const glm::vec4& ay = mvp[1];
+    const glm::vec4& at = mvp[3];
+    const float a = ax.x - ndc.x * ax.w;
+    const float b = ay.x - ndc.x * ay.w;
+    const float c = ax.y - ndc.y * ax.w;
+    const float d = ay.y - ndc.y * ay.w;
+    const float det = a * d - b * c;
+
+    if (std::abs (det) <= 1.1920929e-7f) {
+	return std::nullopt;
+    }
+
+    const float ex = ndc.x * at.w - at.x;
+    const float ey = ndc.y * at.w - at.y;
+    const glm::vec2 point = { (ex * d - b * ey) / det, (a * ey - ex * c) / det };
+
+    if (point.x * ax.w + point.y * ay.w + at.w <= 0.0f) {
+	return std::nullopt;
+    }
+
+    return point;
+}
+
 bool CScene::isHiddenByAncestor (const CObject& object) const {
     const auto& appContext = this->getContext ().getApp ().getContext ();
     const Object* current = &object.getObject ();

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

@@ -12,6 +12,9 @@
 namespace WallpaperEngine::Render {
 class Camera;
 class CObject;
+namespace Objects {
+class CLight;
+}
 }
 
 namespace WallpaperEngine::Render::Wallpapers {
@@ -55,6 +58,8 @@ public:
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
     const glm::vec2* getMousePositionNormalized () const;
+    /** Pixel size of the output being drawn, what WE's window client area (and _rt_FullFrameBuffer) would be */
+    [[nodiscard]] glm::ivec2 getOutputSize () const { return this->m_outputSize; }
     [[nodiscard]] bool isCursorLeftDown () const { return this->m_cursorLeftDown; }
     /** Position fed to shaders as g_ParallaxPosition: 0.5 +- the smoothed, influence-scaled mouse offset */
     const glm::vec2* getParallaxPosition () const;
@@ -97,6 +102,33 @@ public:
     [[nodiscard]] const glm::vec2& getCameraEye () const { return this->m_cameraEye; }
     [[nodiscard]] const CObject* getObject (int id) const;
     [[nodiscard]] CObject* getObject (int id);
+    /** Whether the quad (-half..half, z 0) drawn through mvp covers the clip space point ndc. A planar quad stays
+     *  convex under projection, so this is WE's ray test (sub_14019D5A0) done after projecting */
+    [[nodiscard]] static bool quadContainsPoint (const glm::mat4& mvp, const glm::vec2& half, const glm::vec2& ndc);
+    /** Where the line through the clip space point ndc meets the z = 0 plane drawn through mvp, in that plane's
+     *  coordinates. Nothing when the plane is seen edge on or the point lies behind the eye (sub_14019D5A0) */
+    [[nodiscard]] static std::optional<glm::vec2> quadPlanePoint (const glm::mat4& mvp, const glm::vec2& ndc);
+    /** A passthrough layer's buffer while it draws the objects under it (sub_1401ECB20), passes that would draw onto
+     *  the scene go there instead */
+    struct LayerTarget {
+	std::shared_ptr<const CFBO> fbo;
+	/** The scene buffer's clip space to the layer buffer's */
+	glm::mat4 transform;
+	/** copybackground off: alpha blends with MAX (renderer state flag 0x10) */
+	bool alphaMax;
+	/** The renderer's view projection while the layer draws, what light volumes go through */
+	glm::mat4 viewProjection;
+	/** WE only recomputes the cached view projection lights read once something marks the renderer dirty (+458);
+	 *  the layer doesn't, its first drawn child that isn't a light does */
+	bool viewProjectionApplied = false;
+    };
+    [[nodiscard]] const LayerTarget* getLayerTarget () const;
+    /** Under a passthrough layer, which draws the object into its own buffer instead of the scene (object flag 2) */
+    [[nodiscard]] bool isDrawnByPassthroughLayer (const CObject& object) const;
+    /** Draws the objects under a passthrough layer into target, WE's order and visibility rules (sub_1401ECB20) */
+    void renderPassthroughChildren (int layerId, const LayerTarget& target);
+    /** A point light's volume into the light buffer, through viewProjection (the scene's, or a layer's) */
+    void renderLightVolume (const Objects::CLight& light, const glm::mat4& viewProjection);
     /** True when any group above the object (through "parent") is hidden */
     [[nodiscard]] bool isHiddenByAncestor (const CObject& object) const;
 
@@ -105,6 +137,33 @@ public:
     [[nodiscard]] const glm::vec4* getLightsColorPremultiplied () const { return this->m_lightsColorPremultiplied; }
     /** The same four slots unscaled, (color * intensity, radius), as the 3D shaders read them (g_LightsColorRadius) */
     [[nodiscard]] const glm::vec4* getLightsColorRadius () const { return this->m_lightsColorRadius; }
+    /**
+     * LightingV1 uniforms (sub_140190C80): up to 15 lights per type in the order lit materials index them, and the
+     * counts their LIGHTS_* defines get. Shadow mapping isn't there, so this is WE with its shadow setting off
+     */
+    struct LightingV1 {
+	int points = 0;
+	int spots = 0;
+	int tubes = 0;
+	int directionals = 0;
+	int spotCookies = 0;
+	glm::vec4 pointColor[15] = {};
+	glm::vec4 pointOrigin[15] = {};
+	glm::vec4 spotColor[15] = {};
+	glm::vec4 spotOrigin[15] = {};
+	glm::vec4 spotDirection[15] = {};
+	glm::vec4 spotExponent[15] = {};
+	glm::vec4 tubeColor[15] = {};
+	glm::vec4 tubeOriginA[15] = {};
+	glm::vec4 tubeOriginB[15] = {};
+	glm::vec4 directionalColor[15] = {};
+	glm::vec4 directionalDirection[15] = {};
+	glm::mat4 featureProjection[3] = {};
+	glm::vec4 featureProjectionTransform[3] = {};
+    };
+    [[nodiscard]] const LightingV1& getLightingV1 () const { return this->m_lightingV1; }
+    /** The texture lit materials sample for cookie spots ("_alias_lightCookie"), null without a cookie spot */
+    [[nodiscard]] std::shared_ptr<const TextureProvider> getLightCookie () 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;
@@ -149,6 +208,8 @@ private:
 
     /** Refreshes the light slots from the light objects after scripts ran, like sub_1401D5740 + the 0x5D uniform */
     void updateLights ();
+    void updateLightingV1 ();
+    [[nodiscard]] std::vector<const Light*> sortedLightingV1Lights () const;
     /** Camera object view, parallax camera and perspective layer camera for this frame (sub_1401891A0) */
     void updateCamera ();
     /** Scene camera paths without a camera object, advances them by dt and writes this frame's camera */
@@ -164,6 +225,16 @@ private:
     /** Mouse parallax smoothing, after updateCamera () since a camera object moves the parallax camera too */
     void updateParallax ();
     [[nodiscard]] int nextFreeLightSlot () const;
+    /** One object of the frame's draw loop, with the debug filters and visibility checks */
+    void renderSceneObject (CObject* object);
+    /** Draw call of an object under a passthrough layer, sub_1401ECA70 walks the rest of the subtree depth first */
+    void renderPassthroughSubtree (int parentId, int depth);
+    void renderPassthroughChild (CObject* object);
+    [[nodiscard]] std::vector<CObject*> childrenOf (int id) const;
+    /** Back to front along the camera's view direction, WE's transparent sort (sub_1401865C0) */
+    [[nodiscard]] std::vector<CObject*> sortedByDepth (std::vector<CObject*> objects) const;
+    /** transparentsorting list membership, decided when the object is created (object flag 0x100, sub_14018FF60) */
+    [[nodiscard]] bool isTransparentSorted (const CObject& object);
 
     Render::CObject* createObject (const Object& object);
     void createObjectDependencies (const Object& object);
@@ -216,6 +287,9 @@ private:
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionNormalized = {};
+    /** Cursor in output pixels from the viewport's top left, unclamped and unflipped (WE's ScreenToClient point) */
+    glm::vec2 m_mousePositionViewport = {};
+    glm::ivec2 m_outputSize = {};
     /** Smoothed camera offset from the scene center as a fraction of the scene size, in mouse coordinates */
     glm::vec2 m_cameraParallax = {};
     glm::vec2 m_parallaxPosition = { 0.5f, 0.5f };
@@ -226,10 +300,13 @@ private:
     glm::vec3 m_lightsPosition[4] = {};
     glm::vec4 m_lightsColorPremultiplied[3] = {};
     glm::vec4 m_lightsColorRadius[4] = {};
+    LightingV1 m_lightingV1;
     glm::vec2 m_cursorLastScenePosition = {};
     // object ids the pointer is over / that the current press started on
     std::set<int> m_cursorInside = {};
     std::set<int> m_cursorPressed = {};
+    std::vector<LayerTarget> m_layerTargets = {};
+    std::map<const CObject*, bool> m_transparentSorted = {};
     std::shared_ptr<const CFBO> _rt_4FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_8FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_Bloom = nullptr;

+ 31 - 1
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -2,6 +2,7 @@
 
 #include <algorithm>
 #include <cstring>
+#include <ranges>
 #include <utility>
 
 #include "WallpaperEngine/Data/Model/DynamicValue.h"
@@ -487,9 +488,17 @@ int scriptableobject_property_set (
     // property, so silently accepting the write is the safer default.
     if (auto* property = container->object.tryGetProperty (name); property != nullptr) {
 	container->adapter.getEngine ().assignPropertyJsValue (val, *property, name);
+	return 0;
     }
 
-    return 0;
+    if (std::strcmp (name, "name") == 0 || std::strcmp (name, "id") == 0 || std::strcmp (name, "size") == 0) {
+	return 0;
+    }
+
+    // WE's layer objects are ordinary V8 objects, scripts hang their own state off them
+    // (3378399626 keeps each widget's base origin on the layer). Own properties are found
+    // before the exotic handlers, so later reads and writes never come back here.
+    return JS_DefinePropertyValue (ctx, receiver, atom, JS_DupValue (ctx, val), JS_PROP_C_W_E);
 }
 
 ScriptableObjectAdapter::ScriptableObjectAdapter (ScriptEngine& engine, std::string name) :
@@ -505,15 +514,36 @@ ScriptableObjectAdapter::ScriptableObjectAdapter (ScriptEngine& engine, std::str
 }
 
 JSValue ScriptableObjectAdapter::instantiate (ScriptableObject& object) {
+    JSContext* ctx = this->getEngine ().getContext ();
+
+    if (const auto it = this->m_instances.find (&object); it != this->m_instances.end ()) {
+	return JS_DupValue (ctx, it->second);
+    }
+
     JSValue result = this->ObjectAdapter::instantiate (object);
     JS_SetOpaque (
 	result,
 	new OpaqueScriptableObjectAdapter { .magic = SCRIPTABLE_OPAQUE_MAGIC, .adapter = *this, .object = object }
     );
 
+    this->m_instances.emplace (&object, JS_DupValue (ctx, result));
     return result;
 }
 
+void ScriptableObjectAdapter::forget (const ScriptableObject& object) {
+    if (const auto it = this->m_instances.find (&object); it != this->m_instances.end ()) {
+	JS_FreeValue (this->getEngine ().getContext (), it->second);
+	this->m_instances.erase (it);
+    }
+}
+
+void ScriptableObjectAdapter::clear () {
+    for (const auto& value : this->m_instances | std::views::values) {
+	JS_FreeValue (this->getEngine ().getContext (), value);
+    }
+    this->m_instances.clear ();
+}
+
 JSValue ScriptableObjectAdapter::instantiate (DynamicValue& value) {
     throw std::runtime_error ("Cannot create a ScriptableObject instance from a DynamicValue");
 }

+ 8 - 0
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h

@@ -2,6 +2,8 @@
 
 #include "ObjectAdapter.h"
 
+#include <unordered_map>
+
 namespace WallpaperEngine::Scripting::Adapters {
 class ScriptableObjectAdapter : public ObjectAdapter {
 public:
@@ -14,7 +16,13 @@ public:
      *  nullptr if the value isn't one (wrong type, plain JS object, etc). */
     static ScriptableObject* getObject (JSValueConst value);
 
+    void forget (const ScriptableObject& object);
+    void clear ();
+
 private:
+    /** one JS object per layer like scenescript64 (getLayer sub_181632350 -> sub_181652380), scripts compare
+     *  layers, key maps by them and tag them (3378399626's widget, 3577513994's icon.clicked) */
+    std::unordered_map<const ScriptableObject*, JSValue> m_instances;
     JSClassExoticMethods m_exoticMethods;
     std::string m_name;
 };

+ 16 - 2
src/WallpaperEngine/Scripting/ConsoleObject.cpp

@@ -23,7 +23,14 @@ JSValue console_log (JSContext* ctx, JSValueConst this_val, int argc, JSValueCon
 	const char* str = JS_ToCString (ctx, argv[i]);
 	ScopeGuard guard ([ctx, str] { JS_FreeCString (ctx, str); });
 
-	stream << str;
+	if (i > 0) {
+	    stream << ' ';
+	}
+	if (str != nullptr) {
+	    stream << str;
+	} else {
+	    JS_FreeValue (ctx, JS_GetException (ctx));
+	}
     }
 
     sLog.out (stream.str ());
@@ -42,7 +49,14 @@ JSValue console_error (JSContext* ctx, JSValueConst this_val, int argc, JSValueC
 	const char* str = JS_ToCString (ctx, argv[i]);
 	ScopeGuard guard ([ctx, str] { JS_FreeCString (ctx, str); });
 
-	stream << str;
+	if (i > 0) {
+	    stream << ' ';
+	}
+	if (str != nullptr) {
+	    stream << str;
+	} else {
+	    JS_FreeValue (ctx, JS_GetException (ctx));
+	}
     }
 
     sLog.error (stream.str ());

+ 69 - 7
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -315,6 +315,18 @@ JSValue ScriptEngine::propertyToJs (DynamicValue& value, std::string_view name)
 }
 
 void ScriptEngine::assignPropertyJsValue (JSValue val, DynamicValue& target, std::string_view name) const {
+    // scenescript64 (sub_181620E10, string descriptors) stores ToString of whatever the script assigns, only
+    // undefined/null are ignored: 3577513994 sets a text layer to getDate()'s number
+    if (target.getType () == DynamicValue::String && !JS_IsString (val) && !JS_IsUndefined (val) && !JS_IsNull (val)
+	&& !JS_IsException (val) && JS_VALUE_GET_TAG (val) != JS_TAG_UNINITIALIZED) {
+	const char* str = JS_ToCString (this->m_context, val);
+	if (str != nullptr) {
+	    target.update (std::string (str), DynamicValue::UpdateSource::Script);
+	    JS_FreeCString (this->m_context, str);
+	}
+	return;
+    }
+
     if (!isDegreeProperty (name)) {
 	jsToDynamicValue (this->m_context, val, target);
 	return;
@@ -437,6 +449,10 @@ ScriptEngine::~ScriptEngine () {
 	JS_FreeValue (this->m_context, entry.callback);
     }
 
+    if (this->m_adapters.object) {
+	this->m_adapters.object->clear ();
+    }
+
     JS_FreeValue (this->m_context, this->m_globalThis);
 
     this->m_consoleObject.reset ();
@@ -823,6 +839,30 @@ JSValue ScriptEngine::call (JSValue module, int argc, JSValue argv[], const char
 
 void ScriptEngine::retireScript (const std::string& key) { this->m_retiredScriptKeys.push_back (key); }
 
+void ScriptEngine::dropObjectScripts (const ScriptableObject& object) {
+    this->m_adapters.object->forget (object);
+
+    for (auto& [key, module] : this->m_scriptModules) {
+	if (module.object != &object) {
+	    continue;
+	}
+
+	module.object = nullptr;
+	module.dropped = true;
+	this->retireScript (key);
+    }
+
+    for (const char* global : { "thisLayer", "thisObject" }) {
+	JSValue current = JS_GetPropertyStr (this->m_context, this->m_globalThis, global);
+
+	if (Adapters::ScriptableObjectAdapter::getObject (current) == &object) {
+	    JS_SetPropertyStr (this->m_context, this->m_globalThis, global, JS_UNDEFINED);
+	}
+
+	JS_FreeValue (this->m_context, current);
+    }
+}
+
 bool ScriptEngine::rebindScript (const std::string& key, DynamicValue& newValue) {
     const auto it = this->m_scriptModules.find (key);
 
@@ -837,7 +877,8 @@ bool ScriptEngine::rebindScript (const std::string& key, DynamicValue& newValue)
     LoadedModule replacement { .value = newValue,
 			       .module = it->second.module,
 			       .object = it->second.object,
-			       .propertyName = it->second.propertyName };
+			       .propertyName = it->second.propertyName,
+			       .order = it->second.order };
     this->m_scriptModules.erase (it);
     const auto inserted = this->m_scriptModules.emplace (key, replacement);
     this->m_runningModule = &inserted.first->second;
@@ -888,6 +929,7 @@ void ScriptEngine::queueScript (
 	    .module = JS_UNDEFINED,
 	    .object = &object,
 	    .propertyName = propertyName,
+	    .order = this->m_nextModuleOrder++,
 	}
     );
 
@@ -907,6 +949,10 @@ void ScriptEngine::queueScript (
     // caught by the module machinery and stored as the promise's rejection reason instead.
     JSValue evalResult = JS_EvalFunction (this->m_context, compiledModule);
 
+    // 3378399626's weather widget stores its offsets in shared at the top level from the defaults and only
+    // picks the user's location up in applyUserProperties
+    this->m_scriptPropertiesObject->deliverValues (inserted.first->second.value);
+
     if (JS_PromiseState (this->m_context, evalResult) == JS_PROMISE_REJECTED) {
 	JSValue reason = JS_PromiseResult (this->m_context, evalResult);
 	const char* str = JS_ToCString (this->m_context, reason);
@@ -1156,7 +1202,7 @@ void ScriptEngine::dispatchAnimationEvents () {
 	}
 
 	for (auto& [key, module] : this->m_scriptModules) {
-	    if (&module.value != &clock->getRootValue () || !module.initialized) {
+	    if (&module.value != &clock->getRootValue () || !module.initialized || module.dropped) {
 		continue;
 	    }
 
@@ -1268,11 +1314,11 @@ bool ScriptEngine::hasCursorHandlers (const ScriptableObject& object) {
 }
 
 void ScriptEngine::dispatchCursorEvent (
-    const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec2& localPosition
+    const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec3& localPosition
 ) {
     // Vec3 per WE's lib.sceneScript.d.ts, scripts call add()/subtract() on them
-    const auto makePosition = [this] (const glm::vec2& value) {
-	DynamicValue position (glm::vec3 (value, 0.0f));
+    const auto makePosition = [this] (const glm::vec3& value) {
+	DynamicValue position (value);
 
 	return this->m_adapters.vec3->instantiate (position);
     };
@@ -1286,7 +1332,7 @@ void ScriptEngine::dispatchCursorEvent (
 	this->bindThisLayer (*module.object, &module);
 
 	JSValue event = JS_NewObject (this->m_context);
-	JS_SetPropertyStr (this->m_context, event, "worldPosition", makePosition (worldPosition));
+	JS_SetPropertyStr (this->m_context, event, "worldPosition", makePosition (glm::vec3 (worldPosition, 0.0f)));
 	JS_SetPropertyStr (this->m_context, event, "localPosition", makePosition (localPosition));
 
 	JSValue args[] = { event };
@@ -1357,7 +1403,23 @@ void ScriptEngine::tick () {
 
     // run any pending notifications
 
-    for (auto& [key, module] : this->m_scriptModules) {
+    // scene order like WE, not the order of the keys: 3444535389's bee script reads shared values that the
+    // setup script of an earlier object writes, run before it the bee's position turned NaN for good.
+    // Keys are looked up again per module, an update() may rebind (erase and re-add) an entry.
+    std::vector<std::pair<uint64_t, std::string>> order;
+    order.reserve (this->m_scriptModules.size ());
+    for (const auto& [key, module] : this->m_scriptModules) {
+	order.emplace_back (module.order, key);
+    }
+    std::ranges::sort (order);
+
+    for (const auto& [unused, key] : order) {
+	const auto entry = this->m_scriptModules.find (key);
+	if (entry == this->m_scriptModules.end () || entry->second.dropped) {
+	    continue;
+	}
+	auto& module = entry->second;
+
 	this->m_runningModule = &module;
 
 	// `thisLayer` is a single global binding shared by every module and only set once, at

+ 12 - 3
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -68,6 +68,10 @@ public:
 	std::string propertyName;
 	// -1 until checked, then whether the module exports any cursor* handler
 	int cursorHandlers = -1;
+	// owning object was destroyed, waiting for the retire pass in tick()
+	bool dropped = false;
+	// registration order, which is scene order, what update() calls follow
+	uint64_t order = 0;
     };
     struct JSObjectAdapters {
 	std::unique_ptr<Adapters::VectorAdapter<4>> vec4;
@@ -112,6 +116,10 @@ public:
      * later registerProperty() supersedes it */
     void retireScript (const std::string& key);
 
+    /** Detaches and retires every module attached to object, for an object destroyed while the script context lives on
+     * (setup failed after its properties queued their scripts) */
+    void dropObjectScripts (const ScriptableObject& object);
+
     /** Rebinds an already-running module under key to newValue in place when its script source is identical, so init()
      * does not run twice. Returns false if nothing was rebound */
     bool rebindScript (const std::string& key, DynamicValue& newValue);
@@ -172,11 +180,11 @@ public:
     [[nodiscard]] bool hasCursorHandlers (const ScriptableObject& object);
     /**
      * Calls `handler` (cursorEnter/cursorLeave/cursorMove/cursorDown/cursorUp/cursorClick) on every script running on
-     * the object with an event carrying worldPosition (scene coordinates) and localPosition (offset from the layer's
-     * center)
+     * the object with an event carrying worldPosition (scene coordinates) and localPosition (where the object was hit,
+     * see CScene::dispatchCursorEvents)
      */
     void dispatchCursorEvent (
-	const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec2& localPosition
+	const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec3& localPosition
     );
 
     /** Calls callback (once per playthrough) when player reaches the end of a non-looping video, for
@@ -218,6 +226,7 @@ private:
     std::unique_ptr<ScriptPropertiesObject> m_scriptPropertiesObject;
 
     std::map<std::string, LoadedModule> m_scriptModules = {};
+    uint64_t m_nextModuleOrder = 0;
     std::vector<std::string> m_retiredScriptKeys = {};
 
     LoadedModule* m_runningModule = nullptr;

+ 146 - 44
src/WallpaperEngine/Scripting/ScriptPropertiesObject.cpp

@@ -7,6 +7,10 @@
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 
+#include <algorithm>
+#include <map>
+#include <vector>
+
 using namespace WallpaperEngine::Scripting;
 
 static uint32_t ScriptPropertiesObjectInstanceId = 0;
@@ -15,51 +19,111 @@ std::map<uint32_t, ScriptPropertiesObject&> scriptPropertiesObjectInstances;
 struct OpaqueScriptPropertiesInstance {
     ScriptPropertiesObject& object;
     DynamicValue& value;
+    /** add*() defaults and values a script assigned, WE's scriptProperties is a plain object that keeps them */
+    std::map<std::string, JSValue> assigned {};
+    /** still listed in object.m_undelivered, the finalizer must only touch object while this is set */
+    bool pending = true;
 };
 
+void ScriptPropertiesObject::deliverValues (DynamicValue& value) {
+    std::erase_if (this->m_undelivered, [&] (OpaqueScriptPropertiesInstance* instance) {
+	if (&instance->value != &value) {
+	    return false;
+	}
+
+	for (const auto& [name, unused] : value.getProperties ()) {
+	    if (const auto it = instance->assigned.find (name); it != instance->assigned.end ()) {
+		JS_FreeValue (this->m_engine.getContext (), it->second);
+		instance->assigned.erase (it);
+	    }
+	}
+
+	instance->pending = false;
+	return true;
+    });
+}
+
 struct OpaqueScriptProperties {
     ScriptPropertiesObject& object;
+    /** add*() defaults in declaration order, used when the scene's scriptproperties don't set a name */
+    std::vector<std::pair<std::string, JSValue>> defaults {};
 };
 
-JSValue scriptproperties_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
+namespace {
+// data[0] = the holder object (opaque OpaqueScriptPropertiesInstance), data[1] = the property name
+OpaqueScriptPropertiesInstance* holderOf (JSValueConst holder) {
     JSClassID classId = 0;
+    return static_cast<OpaqueScriptPropertiesInstance*> (JS_GetAnyOpaque (holder, &classId));
+}
 
-    auto* container = static_cast<OpaqueScriptPropertiesInstance*> (JS_GetAnyOpaque (obj_val, &classId));
-
-    const char* name = JS_AtomToCString (ctx, atom);
-
-    // This exotic getter intercepts every property read, not just named slider/setting ones -
-    // well-known symbol lookups (Symbol.toPrimitive, etc) don't stringify to a C string here,
-    // which isn't an error, just "not one of our named properties".
-    if (name == nullptr) {
+JSValue scriptproperty_get (JSContext* ctx, JSValueConst, int, JSValueConst*, int, JSValue* data) {
+    auto* holder = holderOf (data[0]);
+    const char* name = JS_ToCString (ctx, data[1]);
+    if (holder == nullptr || name == nullptr) {
+	JS_FreeCString (ctx, name);
 	return JS_UNDEFINED;
     }
-
     ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
 
-    try {
-	auto& properties = container->value.getProperties ();
-	const auto it = properties.find (name);
+    if (const auto it = holder->assigned.find (name); it != holder->assigned.end ()) {
+	return JS_DupValue (ctx, it->second);
+    }
 
-	if (it == properties.end ()) {
-	    return JS_UNDEFINED;
+    try {
+	const auto& properties = holder->value.getProperties ();
+	if (const auto it = properties.find (name); it != properties.end ()) {
+	    return holder->object.getEngine ().dynamicToJs (*it->second->value);
 	}
-
-	return container->object.getEngine ().dynamicToJs (*it->second->value);
     } catch (const std::exception& e) {
 	return JS_ThrowTypeError (ctx, "scriptProperties.%s: %s", name, e.what ());
     }
+
+    return JS_UNDEFINED;
 }
 
-int scriptproperties_property_set (
-    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
-) {
-    // do not support setting properties
-    return -1;
+JSValue scriptproperty_set (JSContext* ctx, JSValueConst, int argc, JSValueConst* argv, int, JSValue* data) {
+    auto* holder = holderOf (data[0]);
+    const char* name = JS_ToCString (ctx, data[1]);
+    if (holder != nullptr && name != nullptr && argc > 0) {
+	auto& slot = holder->assigned[name];
+	JS_FreeValue (ctx, slot);
+	slot = JS_DupValue (ctx, argv[0]);
+    }
+    JS_FreeCString (ctx, name);
+    return JS_UNDEFINED;
 }
+} // namespace
+
+JSValue scriptpropertiescreator_add (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    JSClassID classId = 0;
+    auto* container = static_cast<OpaqueScriptProperties*> (JS_GetAnyOpaque (this_val, &classId));
+
+    // WE's baseclasses.js: vars[name] = options.value, combos take their first option's value
+    if (container != nullptr && argc > 0 && JS_IsObject (argv[0])) {
+	JSValue nameValue = JS_GetPropertyStr (ctx, argv[0], "name");
+	const char* name = JS_ToCString (ctx, nameValue);
+	JSValue value = JS_UNDEFINED;
+
+	if (magic == 1) {
+	    JSValue options = JS_GetPropertyStr (ctx, argv[0], "options");
+	    JSValue first = JS_GetPropertyUint32 (ctx, options, 0);
+	    value = JS_GetPropertyStr (ctx, first, "value");
+	    JS_FreeValue (ctx, first);
+	    JS_FreeValue (ctx, options);
+	} else {
+	    value = JS_GetPropertyStr (ctx, argv[0], "value");
+	}
+
+	if (name != nullptr) {
+	    container->defaults.emplace_back (name, value);
+	} else {
+	    JS_FreeValue (ctx, value);
+	}
+
+	JS_FreeCString (ctx, name);
+	JS_FreeValue (ctx, nameValue);
+    }
 
-JSValue scriptpropertiescreator_add (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    // TODO: properly implement this chain at some point
     // this_val is a borrowed reference: returning it as-is under-counts its refcount by one per
     // chained .addSlider() call, freeing the creator object while script code still uses it.
     return JS_DupValue (ctx, this_val);
@@ -71,33 +135,69 @@ JSValue scriptpropertiescreator_finish (JSContext* ctx, JSValueConst this_val, i
 
     const auto* module = container->object.getEngine ().getRunningModule ();
 
-    sLog.debug ("scriptpropertiescreator_finish: running module = ", static_cast<const void*> (module));
-
     if (module == nullptr) {
 	sLog.error ("scriptpropertiescreator_finish: no running module - scriptProperties will be undefined");
 	return JS_UNDEFINED;
     }
 
-    JSValue result = JS_NewObjectClass (ctx, container->object.getPropertiesClassId ());
-    JS_SetOpaque (
-	result,
-	new OpaqueScriptPropertiesInstance { .object = container->object,
-					     .value = container->object.getEngine ().getRunningModule ()->value }
-    );
+    // the holder keeps the module's value alive for the accessors, scripts never see it
+    JSValue holder = JS_NewObjectClass (ctx, container->object.getPropertiesClassId ());
+    auto* instance = new OpaqueScriptPropertiesInstance { .object = container->object, .value = module->value };
+    JS_SetOpaque (holder, instance);
+
+    // like WE's createScriptProperties (baseclasses.js) a plain object whose settings are its own enumerable
+    // properties, so hasOwnProperty, Object.keys and assignments work. They hold the add*() defaults until
+    // the module body has run, then read the scene's (user bound) value (deliverValues)
+    JSValue result = JS_NewObject (ctx);
+    std::vector<std::string> names;
+    for (auto& [name, value] : container->defaults) {
+	if (std::ranges::find (names, name) == names.end ()) {
+	    names.push_back (name);
+	    instance->assigned[name] = JS_DupValue (ctx, value);
+	}
+    }
+    container->object.m_undelivered.push_back (instance);
+
+    for (const auto& name : names) {
+	JSValue data[] = { holder, JS_NewString (ctx, name.c_str ()) };
+	JSValue getter = JS_NewCFunctionData (ctx, scriptproperty_get, 0, 0, 2, data);
+	JSValue setter = JS_NewCFunctionData (ctx, scriptproperty_set, 1, 0, 2, data);
+	JS_FreeValue (ctx, data[1]);
+
+	const JSAtom atom = JS_NewAtom (ctx, name.c_str ());
+	JS_DefinePropertyGetSet (ctx, result, atom, getter, setter, JS_PROP_ENUMERABLE | JS_PROP_CONFIGURABLE);
+	JS_FreeAtom (ctx, atom);
+    }
 
+    JS_FreeValue (ctx, holder);
     return result;
 }
 
 void scriptpropertiescreator_finalizer (JSRuntime* rt, JSValueConst val) {
     JSClassID classId = 0;
+    auto* container = static_cast<OpaqueScriptProperties*> (JS_GetAnyOpaque (val, &classId));
 
-    delete static_cast<OpaqueScriptProperties*> (JS_GetAnyOpaque (val, &classId));
+    for (auto& [name, value] : container->defaults) {
+	JS_FreeValueRT (rt, value);
+    }
+
+    delete container;
 }
 
 void scriptproperties_finalizer (JSRuntime* rt, JSValueConst val) {
     JSClassID classId = 0;
+    auto* instance = static_cast<OpaqueScriptPropertiesInstance*> (JS_GetAnyOpaque (val, &classId));
+
+    // the ScriptPropertiesObject is gone by the time JS_FreeRuntime() finalizes leftover instances
+    if (instance->pending) {
+	std::erase (instance->object.m_undelivered, instance);
+    }
+
+    for (auto& [name, value] : instance->assigned) {
+	JS_FreeValueRT (rt, value);
+    }
 
-    delete static_cast<OpaqueScriptPropertiesInstance*> (JS_GetAnyOpaque (val, &classId));
+    delete instance;
 }
 
 JSValue
@@ -120,10 +220,6 @@ ScriptPropertiesObject::ScriptPropertiesObject (ScriptEngine& engine, Render::Wa
     m_propertiesClassId (0) {
     scriptPropertiesObjectInstances.emplace (this->m_instanceId, *this);
 
-    this->m_exoticMethods = {
-	.get_property = scriptproperties_property_get,
-	.set_property = scriptproperties_property_set,
-    };
     this->m_creatorDefinition = {
 	.class_name = "IScriptPropertiesCreator",
 	.finalizer = scriptpropertiescreator_finalizer,
@@ -135,7 +231,6 @@ ScriptPropertiesObject::ScriptPropertiesObject (ScriptEngine& engine, Render::Wa
     this->m_propertiesDefinition = {
 	.class_name = "IScriptProperties",
 	.finalizer = scriptproperties_finalizer,
-	.exotic = &this->m_exoticMethods,
     };
     JS_NewClassID (this->m_engine.getRuntime (), &this->m_propertiesClassId);
     JS_NewClass (this->m_engine.getRuntime (), this->m_propertiesClassId, &this->m_propertiesDefinition);
@@ -146,24 +241,24 @@ ScriptPropertiesObject::ScriptPropertiesObject (ScriptEngine& engine, Render::Wa
 
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "addSlider",
-	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_add, "addSlider", 0), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scriptpropertiescreator_add, "addSlider", 1, JS_CFUNC_generic_magic, 0), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "addCheckbox",
-	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_add, "addCheckbox", 0),
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scriptpropertiescreator_add, "addCheckbox", 1, JS_CFUNC_generic_magic, 0),
 	JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "addText",
-	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_add, "addText", 0), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scriptpropertiescreator_add, "addText", 1, JS_CFUNC_generic_magic, 0), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "addCombo",
-	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_add, "addCombo", 0), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scriptpropertiescreator_add, "addCombo", 1, JS_CFUNC_generic_magic, 1), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "addColor",
-	JS_NewCFunction (this->m_engine.getContext (), scriptpropertiescreator_add, "addColor", 0), JS_PROP_ENUMERABLE
+	JS_NewCFunctionMagic (this->m_engine.getContext (), scriptpropertiescreator_add, "addColor", 1, JS_CFUNC_generic_magic, 0), JS_PROP_ENUMERABLE
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_creatorPrototype, "finish",
@@ -185,6 +280,13 @@ ScriptPropertiesObject::ScriptPropertiesObject (ScriptEngine& engine, Render::Wa
 }
 
 ScriptPropertiesObject::~ScriptPropertiesObject () {
+    for (auto* instance : this->m_undelivered) {
+	instance->pending = false;
+    }
+
+    this->m_undelivered.clear ();
+    scriptPropertiesObjectInstances.erase (this->m_instanceId);
+
     JS_FreeValue (this->m_engine.getContext (), this->m_creatorPrototype);
     JS_FreeValue (this->m_engine.getContext (), this->m_propertiesPrototype);
 }

+ 14 - 1
src/WallpaperEngine/Scripting/ScriptPropertiesObject.h

@@ -1,9 +1,15 @@
 #pragma once
 #include "quickjs.h"
 
+#include <vector>
+
 namespace WallpaperEngine::Render::Wallpapers {
 class CScene;
 }
+namespace WallpaperEngine::Data::Model {
+class DynamicValue;
+}
+struct OpaqueScriptPropertiesInstance;
 namespace WallpaperEngine::Scripting {
 class ScriptEngine;
 class ScriptPropertiesObject {
@@ -18,6 +24,14 @@ public:
     [[nodiscard]] JSClassID getPropertiesClassId () const { return m_propertiesClassId; }
     [[nodiscard]] ScriptEngine& getEngine () const { return m_engine; }
 
+    /**
+     * WE (wallpaper64 sub_140175880 -> sub_140174DC0 -> baseclasses.js _Internal.updateScriptProperties) hands a
+     * script its scene values right after evaluating the module, so top-level code still sees the add*() defaults
+     */
+    void deliverValues (Data::Model::DynamicValue& value);
+
+    std::vector<OpaqueScriptPropertiesInstance*> m_undelivered;
+
 protected:
     Render::Wallpapers::CScene& m_scene;
     ScriptEngine& m_engine;
@@ -31,6 +45,5 @@ protected:
     JSClassID m_propertiesClassId;
     JSClassDef m_propertiesDefinition;
     JSValue m_propertiesPrototype;
-    JSClassExoticMethods m_exoticMethods;
 };
 }

BIN
tools/__pycache__/unpack_pkg.cpython-313.pyc


+ 20 - 3
tools/headless_render.sh

@@ -58,16 +58,33 @@ fi
 
 XVFB_DISPLAY="${HEADLESS_RENDER_DISPLAY:-:99}"
 XVFB_SOCKET="/tmp/.X11-unix/X${XVFB_DISPLAY#:}"
+# a private runtime dir and no desktop variables: libwayland falls back to $XDG_RUNTIME_DIR/wayland-0 when
+# WAYLAND_DISPLAY is unset, so a build without the headless driver would otherwise open a window on the real
+# desktop session (and reach its D-Bus / audio server)
+RUNTIME_DIR="$(mktemp -d /tmp/lwe-headless-run.XXXXXX)"
+chmod 700 "$RUNTIME_DIR"
+DBUS_PID=
+trap 'kill $DBUS_PID 2>/dev/null; rm -rf "$RUNTIME_DIR"' EXIT
+# the engine needs a session bus (MPRIS media source), it gets a private one
+BUS_ADDRESS=unix:path=/nonexistent-bus
+if command -v dbus-daemon >/dev/null; then
+    DBUS_PID=$(dbus-daemon --session --fork --print-pid --address="unix:path=$RUNTIME_DIR/bus")
+    BUS_ADDRESS="unix:path=$RUNTIME_DIR/bus"
+fi
+ISOLATE=(-u WAYLAND_DISPLAY -u WAYLAND_SOCKET -u KDE_FULL_SESSION -u KDE_SESSION_VERSION
+    -u DESKTOP_SESSION -u XDG_CURRENT_DESKTOP -u XDG_SESSION_DESKTOP XDG_RUNTIME_DIR="$RUNTIME_DIR"
+    DBUS_SESSION_BUS_ADDRESS="$BUS_ADDRESS"
+    PULSE_SERVER=unix:/nonexistent-pulse PIPEWIRE_REMOTE=/nonexistent-pipewire)
 if [ -n "$USE_GPU" ]; then
-    SESSION=(env -u DISPLAY -u WAYLAND_DISPLAY XDG_SESSION_TYPE=headless)
+    SESSION=(env -u DISPLAY "${ISOLATE[@]}" XDG_SESSION_TYPE=headless)
 else
-    SESSION=(env -u WAYLAND_DISPLAY DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11)
+    SESSION=(env "${ISOLATE[@]}" DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11)
 fi
 
 if [ -z "$USE_GPU" ] && [ ! -S "$XVFB_SOCKET" ]; then
     Xvfb "$XVFB_DISPLAY" -screen 0 1920x1080x24 &
     XVFB_PID=$!
-    trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT
+    trap 'kill "$XVFB_PID" $DBUS_PID 2>/dev/null || true; rm -rf "$RUNTIME_DIR"' EXIT
     # give it a moment to bind before launching anything against it
     for _ in $(seq 1 20); do
         [ -S "$XVFB_SOCKET" ] && break

+ 16 - 0
tools/regression/regress.py

@@ -26,6 +26,7 @@ Needs Xvfb, gcc and Pillow (tools/requirements.txt).
 """
 
 import argparse
+import atexit
 import concurrent.futures
 import html
 import json
@@ -36,6 +37,7 @@ import shutil
 import signal
 import subprocess
 import sys
+import tempfile
 import time
 from pathlib import Path
 
@@ -235,6 +237,9 @@ def render_once (args, env, folder, shot, log_path, properties):
         command += ['--set-property', value]
 
     command.append (str (folder))
+    # a private session bus per render, the engine needs one and must not use the desktop's
+    if shutil.which ('dbus-run-session'):
+        command = ['dbus-run-session', '--'] + command
 
     started = time.monotonic ()
     status = 'timeout'
@@ -276,13 +281,24 @@ def render (args, preload, display, wallpaper):
     env.pop ('WAYLAND_DISPLAY', None)
     env.pop ('WAYLAND_SOCKET', None)
     env.pop ('DISPLAY', None)
+    # nothing may reach the desktop session: libwayland and D-Bus fall back to sockets in XDG_RUNTIME_DIR, so a
+    # build without the headless driver would open a window on the real desktop
+    for name in ('DBUS_SESSION_BUS_ADDRESS', 'KDE_FULL_SESSION', 'KDE_SESSION_VERSION', 'DESKTOP_SESSION',
+                 'XDG_CURRENT_DESKTOP', 'XDG_SESSION_DESKTOP'):
+        env.pop (name, None)
+    runtime_dir = tempfile.mkdtemp (prefix = 'lwe-regress-run-')
+    atexit.register (shutil.rmtree, runtime_dir, True)
     env.update ({
+        'XDG_RUNTIME_DIR': runtime_dir,
         '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',
+        # no audio server: whatever the machine is playing would otherwise feed the audio visualizers
+        'PULSE_SERVER': 'unix:/nonexistent-pulse',
+        'PIPEWIRE_REMOTE': '/nonexistent-pipewire',
     })
     if args.gpu:
         env['XDG_SESSION_TYPE'] = 'headless'

+ 79 - 0
tools/wecompare/README.md

@@ -0,0 +1,79 @@
+# wecompare - library inventory and comparison against the real Wallpaper Engine
+
+Tools to scan a workshop library for features, render every wallpaper with this engine and with the real
+Wallpaper Engine (2.8.x, Windows build under Wine + DXVK on an isolated display), and compare the two.
+
+Data locations (all overridable):
+
+| what | default | env |
+|---|---|---|
+| workshop items | `/workspace/SteamLibrary/steamapps/workshop/content/431960` | `LWE_WORKSHOP_DIR` |
+| WE assets | `/workspace/SteamLibrary/steamapps/common/wallpaper_engine/assets` | `LWE_ASSETS_DIR` |
+| feature database, results | `~/.local/share/lwe-library` | `LWE_LIBRARY_DIR` |
+| Wine prefix + WE install copy | `~/.local/share/we_live` | `WE_LIVE_DIR` |
+| WE reference frames | `~/.local/share/we_live/refs/<id>/f*.png` | `REFS` |
+
+## Scripts
+
+- `scan.py [ids]` - feature scan (reads scene.pkg in memory, well under a second for 100+ items). Writes
+  `features.json` and `FEATURES.md` (feature -> item ids, rarest first) and prints new/removed items.
+- `render_we.py [ids | scene paths]` - real WE reference frames, ~12s per item (~30s for 100+ object scenes):
+  a fresh WE per item that is captured as soon as the first frame is rendered instead of after fixed sleeps.
+  Skips items that already have frames unless `FORCE=1`. Scene dirs/files (test variants) go to
+  `$REFS/<dir name>`. `FRAMES`, `INTERVAL`, `SETTLE`, `SWITCH_TIMEOUT` (per attempt), `RETRIES` (new instances while the window stays flat, video-texture scenes need that sometimes) tune the capture. Reusing one
+  instance via `-control openWallpaper` doesn't work under Wine (the command only re-shows the UI window).
+- `render_ours.sh <outdir> [ids]` - headless GPU render of this engine at the WE window size (1920x1058),
+  frame 600 at 30 fps. `LWE=<dir>` picks another build (the dir needs its own copy of the lib .so).
+- `compare.py <ours dir> <review dir> [ids]` - scores against the closest WE frame (they animate), writes
+  `<id>.png` (WE | ours, diff heat below), 4-item `sheet_*.png` for skimming and `scores.json`
+  (mean diff, area with big differences, WE frame-to-frame motion, global shift by phase correlation).
+- `frames.py <id> <ours.png> <out.png>` - all WE frames plus ours in one grid, to tell animation from real
+  differences.
+- `crop.py <id> <ours.png> <out.png> x y w h [zoom]` - same region of both side by side.
+
+`wine/` has the lower level real-WE helpers:
+
+- `we_display.sh` - starts the isolated display :98 (headless weston + Xwayland in a private runtime dir).
+- `we_full.sh <scene> <outdir> [count] [interval]` - single cold run with full-screen grabs.
+- `we_cursor.sh <scene> <outdir> "x,y x,y" [settle]` - warps the pointer before each grab (parallax, cursor).
+- `we_capture.sh <scene> [secs]` - RenderDoc capture of one WE frame (inspect with `tools/rdc_dump.py` on :98,
+  `rdscripts/` has the replay scripts).
+- `repack_pkg.py in.pkg out.pkg name=file ...` - replaces files inside a scene.pkg (test variants of scenes
+  whose texture names Wine can't open in folder mode).
+
+## Regression pass after engine changes
+
+    ./render_ours.sh /tmp/ours [ids] && ./compare.py /tmp/ours /tmp/review [ids]
+
+WE refs only need re-rendering when the item was updated on the workshop (`mtime` in features.json). Pick
+targets by feature from `FEATURES.md`.
+
+## Setting up the WE side (once per `WE_LIVE_DIR`)
+
+    mkdir -p $WE_LIVE_DIR/we28 && cd $WE_LIVE_DIR/we28
+    cp <wallpaper_engine>/distribution/wallpaper64.exe <wallpaper_engine>/distribution/version.json .
+    ln -s <wallpaper_engine>/distribution/bin bin; ln -s <wallpaper_engine>/assets assets
+    ln -s <wallpaper_engine>/distribution/plugins plugins
+    bash wine/we_display.sh
+    WINEPREFIX=$WE_LIVE_DIR/wineprefix DISPLAY=:98 wine64 wineboot --init
+    WINEPREFIX=$WE_LIVE_DIR/wineprefix dxvk-setup install -s -y
+
+For RenderDoc: `mkdir -p $WE_LIVE_DIR/vklayer; cp wine/renderdoc_capture.json $WE_LIVE_DIR/vklayer/;
+cp wine/rd_trigger.py $WE_LIVE_DIR/`, and `cp wine/qrenderdoc_analytics.json ~/.local/share/qrenderdoc/analytics.json`
+(the first-run analytics dialog blocks the trigger script otherwise). Set `"postprocessing": "enabled"` in
+`we28/config.json` (WE's default; disabled turns bloom off and shows up as false differences).
+
+## Pitfalls
+
+- Audio: if the desktop's audio server socket is reachable (e.g. a shared `/run/user/<uid>/pulse`), Wine and
+  this engine both connect to it. WE then plays wallpaper sounds on real speakers and both sides pick up real
+  system audio, so audio-reactive layers differ between runs. All scripts point `PULSE_SERVER` /
+  `PIPEWIRE_REMOTE` at dead sockets; keep that in anything new (verify with `strace -f -e trace=connect`).
+- Never use :0 for WE. DXVK crashes on Xvfb (0 Hz mode), hence weston + Xwayland on :98.
+- Scores are only a triage hint: scroll/shake/particles, wall-clock texts and heavily downscaled 8K scenes score
+  high while matching. Check `frames.py` before calling something different.
+- Web wallpapers can't be compared (WE's CEF under Wine renders a white page, this engine renders black
+  headless). The video wallpaper type is black in WE under Wine (video textures inside scenes do play).
+- Texts using `systemfont_*` aren't drawn by WE under Wine (the prefix has no Windows fonts).
+- Folder scenes with CJK texture names fail to load textures in WE under Wine: pass scene.pkg (or repack).
+- Crop: WE window = `[30:1080, 4:1920]` of the :98 screen, ours `[0:1050, 0:1916]`.

BIN
tools/wecompare/__pycache__/scan.cpython-313.pyc


+ 84 - 0
tools/wecompare/compare.py

@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Compares our renders against the real-WE reference frames.
+
+    compare.py <ours dir> <review out dir> [ids...]
+
+Scores each item against the closest of its WE frames (they animate), writes <review>/<id>.png
+(top: WE | ours, bottom: diff heat | heat over ours) and small side-by-side sheets for skimming,
+and prints/stores the scores in <review>/scores.json. WE window crop = [30:1080, 4:1920] of the
+:98 screen, ours rendered at 1920x1058 -> [0:1050, 0:1916].
+"""
+import glob, json, os, re, sys
+import numpy as np
+from PIL import Image
+
+REFS = os.environ.get('REFS', os.path.expanduser('~/.local/share/we_live/refs'))
+
+
+def log_problems(path):
+    if not os.path.exists(path):
+        return []
+    out = []
+    for line in open(path, errors='replace'):
+        if re.search(r'glslang|shader.*(fail|error)|exception|Segmentation|terminate called|could not|cannot|failed', line, re.I) \
+                and 'NoReply' not in line and 'dbus' not in line.lower():
+            out.append(line.strip()[:200])
+    return out
+
+
+def shift(a, b):
+    """global (dx, dy) in pixels of b relative to a, phase correlation on grey images"""
+    ga, gb = a.mean(axis=2), b.mean(axis=2)
+    fa, fb = np.fft.fft2(ga - ga.mean()), np.fft.fft2(gb - gb.mean())
+    r = fa.conj() * fb
+    c = np.abs(np.fft.ifft2(r / (np.abs(r) + 1e-6)))
+    y, x = np.unravel_index(np.argmax(c), c.shape)
+    h, w = ga.shape
+    return int(x if x < w // 2 else x - w), int(y if y < h // 2 else y - h), round(float(c.max()), 3)
+
+
+def main():
+    ours_dir, rev = sys.argv[1], sys.argv[2]
+    os.makedirs(rev, exist_ok=True)
+    ids = sys.argv[3:] or sorted(os.path.basename(p)[:-4] for p in glob.glob(ours_dir + '/*.png'))
+    spath = os.path.join(rev, 'scores.json')
+    scores = json.load(open(spath)) if os.path.exists(spath) else {}
+    thumbs = []
+    for id_ in ids:
+        we = [np.asarray(Image.open(f).convert('RGB'))[30:1080, 4:1920].astype(np.float32)
+              for f in sorted(glob.glob(f'{REFS}/{id_}/f*.png'))]
+        p = f'{ours_dir}/{id_}.png'
+        rec = {'we_frames': len(we), 'ours': os.path.exists(p), 'our_log': log_problems(f'{ours_dir}/{id_}.log')[:8]}
+        if not we or not rec['ours']:
+            scores[id_] = rec
+            print(id_, 'missing', rec)
+            continue
+        ours = np.asarray(Image.open(p).convert('RGB'))[0:1050, 0:1916].astype(np.float32)
+        diffs = [np.abs(w - ours).mean() for w in we]
+        best = we[int(np.argmin(diffs))]
+        motion = float(np.mean([np.abs(we[i] - we[i + 1]).mean() for i in range(len(we) - 1)])) if len(we) > 1 else 0.0
+        # blank WE frames (load failure) show up as near-uniform images
+        rec.update(diff=round(float(min(diffs)), 2), we_motion=round(motion, 2),
+                   we_std=round(float(best.std()), 1), ours_std=round(float(ours.std()), 1),
+                   big_diff_area=round(float((np.abs(best - ours).mean(axis=2) > 48).mean()), 3),
+                   shift=shift(best, ours))
+        scores[id_] = rec
+        heat = np.clip(np.abs(best - ours).mean(axis=2) * 3, 0, 255)
+        top = np.concatenate([best, ours], axis=1)
+        grey = np.asarray(Image.fromarray(ours.astype(np.uint8)).convert('L').convert('RGB')).astype(np.float32) / 3
+        bottom = np.concatenate([np.stack([heat] * 3, axis=2), grey + np.stack([heat, heat / 4, heat / 4], axis=2)], axis=1)
+        Image.fromarray(np.clip(np.concatenate([top, bottom], axis=0), 0, 255).astype(np.uint8)).resize((1916, 1050)).save(f'{rev}/{id_}.png')
+        thumbs.append((id_, Image.fromarray(top.astype(np.uint8)).resize((1280, 350))))
+        print(f"{id_} diff={rec['diff']} area={rec['big_diff_area']} we-motion={rec['we_motion']} stds={rec['we_std']}/{rec['ours_std']} shift={rec['shift']}")
+    json.dump(scores, open(spath, 'w'), indent=1, sort_keys=True)
+    # sheets of 4 (WE left, ours right) for skimming
+    for n in range(0, len(thumbs), 4):
+        chunk = thumbs[n:n + 4]
+        sheet = Image.new('RGB', (1280, 360 * len(chunk)), (255, 0, 255))
+        for i, (id_, t) in enumerate(chunk):
+            sheet.paste(t, (0, i * 360))
+        sheet.save(f'{rev}/sheet_{n // 4:02d}_{"_".join(c[0] for c in chunk)}.png')
+
+
+if __name__ == '__main__':
+    main()

+ 17 - 0
tools/wecompare/crop.py

@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""crop.py <id> <ours.png> <out.png> x y w h [scale] - same region (window coords) of the best WE frame and ours, side by side"""
+import glob, os, sys
+import numpy as np
+from PIL import Image
+id_, ours, out = sys.argv[1:4]
+x, y, w, h = map(int, sys.argv[4:8])
+k = int(sys.argv[8]) if len(sys.argv) > 8 else 2
+o = Image.open(ours).convert('RGB').crop((0, 0, 1916, 1050))
+fs = sorted(glob.glob(os.path.expanduser(f'~/.local/share/we_live/refs/{id_}/f*.png')))
+we = [Image.open(f).convert('RGB').crop((4, 30, 1920, 1080)) for f in fs]
+oa = np.asarray(o, np.float32)
+b = min(we, key=lambda im: np.abs(np.asarray(im, np.float32) - oa).mean())
+g = Image.new('RGB', (2 * w * k + 8, h * k), (255, 0, 255))
+g.paste(b.crop((x, y, x + w, y + h)).resize((w * k, h * k), Image.NEAREST), (0, 0))
+g.paste(o.crop((x, y, x + w, y + h)).resize((w * k, h * k), Image.NEAREST), (w * k + 8, 0))
+g.save(out)

+ 11 - 0
tools/wecompare/frames.py

@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""frames.py <id> <ours.png> <out.png> - all WE ref frames + ours in one grid (for animated items)"""
+import glob, os, sys
+from PIL import Image
+id_, ours, out = sys.argv[1:4]
+fs = sorted(glob.glob(os.path.expanduser(f'~/.local/share/we_live/refs/{id_}/f*.png')))
+ims = [Image.open(f).convert('RGB').crop((4, 30, 1920, 1080)) for f in fs] + [Image.open(ours).convert('RGB').crop((0, 0, 1916, 1050))]
+g = Image.new('RGB', (1920, 350 * ((len(ims) + 2) // 3)), (255, 0, 255))
+for i, im in enumerate(ims):
+    g.paste(im.resize((638, 350)), ((i % 3) * 640, (i // 3) * 350))
+g.save(out)

+ 20 - 0
tools/wecompare/render_ours.sh

@@ -0,0 +1,20 @@
+#!/bin/bash
+# usage: render_ours.sh <outdir> [ids...]  (default: every item in the scan.py database)
+# headless GPU render at WE's window size (1920x1058), frame 600 @30fps -> <outdir>/<id>.png + .log
+# LWE=<dir with linux-wallpaperengine + its lib .so> picks the build (default: this checkout's build/output)
+# audio goes to dead sockets: the engine captures from the desktop's audio server even with --silent
+W=${LWE_WORKSHOP_DIR:-/workspace/SteamLibrary/steamapps/workshop/content/431960}
+A=${LWE_ASSETS_DIR:-/workspace/SteamLibrary/steamapps/common/wallpaper_engine/assets}
+LIB=${LWE_LIBRARY_DIR:-$HOME/.local/share/lwe-library}
+SRC=$(cd "$(dirname "$0")/../.." && pwd)
+LWE=${LWE:-$SRC/build/output}
+OUT=$1; shift; mkdir -p "$OUT"
+# a copy under another name so we_manager's shutdown pkill doesn't hit it
+cp -f $LWE/linux-wallpaperengine $LWE/lwe-regress-bin
+ids=("$@"); [ ${#ids[@]} -eq 0 ] && ids=($(python3 -c "import json;print(' '.join(sorted(json.load(open('$LIB/features.json')))))"))
+for id in "${ids[@]}"; do
+  PULSE_SERVER=unix:/nonexistent-pulse PIPEWIRE_REMOTE=/nonexistent-pipewire \
+  HEADLESS_RENDER_SIZE=1920x1058 HEADLESS_RENDER_DELAY=${DELAY:-600} HEADLESS_RENDER_TIMEOUT=90 LD_LIBRARY_PATH=$LWE \
+    $SRC/tools/headless_render.sh $LWE/lwe-regress-bin "$OUT/$id.png" --assets-dir $A --fps 30 --silent $W/$id > "$OUT/$id.log" 2>&1 < /dev/null
+  echo "ours $id rc=$? $( [ -f "$OUT/$id.png" ] && echo ok || echo NO-PNG)"
+done

+ 171 - 0
tools/wecompare/render_we.py

@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+"""Real Wallpaper Engine reference frames under Wine, a fresh WE instance per item.
+
+    render_we.py [ids...]      (default: every item in the scan.py database)
+    render_we.py <dir|scene.json|scene.pkg> ...   test scenes, frames go to $REFS/<dir name>
+
+Writes $REFS/<id>/f*.png (default ~/.local/share/we_live/refs; full :98 screen, window at +4+30) + we.log.
+Items that already have frames are skipped unless FORCE=1. Instead of fixed sleeps it waits until the window
+isn't a flat colour any more (the first rendered frame), then SETTLE seconds, then FRAMES frames INTERVAL
+seconds apart (env overrides). -control openWallpaper sent to a running instance doesn't switch wallpapers under
+Wine (it only brings the UI window back), hence one instance per item.
+Audio is pointed at dead sockets, see README.md. Setup of the Wine prefix: README.md.
+"""
+import os, subprocess, sys, time
+import numpy as np
+import mss
+from PIL import Image
+from Xlib import display as xdisplay
+
+S = os.environ.get('WE_LIVE_DIR', os.path.expanduser('~/.local/share/we_live'))
+HERE = os.path.dirname(os.path.abspath(__file__)) + '/wine'
+W = os.environ.get('LWE_WORKSHOP_DIR', '/workspace/SteamLibrary/steamapps/workshop/content/431960')
+LIB = os.environ.get('LWE_LIBRARY_DIR', os.path.expanduser('~/.local/share/lwe-library'))
+REFS = os.environ.get('REFS', S + '/refs')
+D = ':98'
+FRAMES = int(os.environ.get('FRAMES', 5))
+INTERVAL = float(os.environ.get('INTERVAL', 1.0))
+SETTLE = float(os.environ.get('SETTLE', 4.0))
+TIMEOUT = float(os.environ.get('SWITCH_TIMEOUT', 30))
+RETRIES = int(os.environ.get('RETRIES', 3))
+
+# Wine must only see the isolated :98, not the desktop session (wayland-0, D-Bus) in the shared runtime dir
+env = {k: v for k, v in os.environ.items() if k not in ('WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'DBUS_SESSION_BUS_ADDRESS')}
+env = dict(env, XDG_RUNTIME_DIR=S + '/run', WINEPREFIX=S + '/wineprefix', DISPLAY=D, WINEDEBUG='-all', DXVK_LOG_PATH=S,
+           PULSE_SERVER=f'unix:{S}/run/no-pulse', PIPEWIRE_REMOTE=f'{S}/run/no-pipewire', ALSA_CONFIG_PATH=S + '/run/asound-null.conf')
+WINE = '/usr/lib/wine/wine64'
+we_proc = None
+log = None
+
+
+def winpath(p):
+    return 'Z:' + p.replace('/', '\\')
+
+
+def grab(m):
+    s = m.grab({'left': 0, 'top': 0, 'width': 1920, 'height': 1080})
+    return Image.frombytes('RGB', s.size, s.rgb)
+
+
+def small(img):
+    return np.asarray(img.crop((4, 30, 1920, 1080)).resize((240, 131))).astype(np.float32)
+
+
+def hide_ui():
+    d = xdisplay.Display(D)
+    root = d.screen().root
+
+    def walk(w):
+        # windows can disappear while walking the tree
+        try:
+            children = w.query_tree().children
+        except Exception:
+            return
+        for c in children:
+            yield c
+            yield from walk(c)
+    for w in walk(root):
+        try:
+            if w.get_wm_name() == 'Wallpaper UI':
+                w.unmap()
+        except Exception:
+            pass
+    try:
+        top = root.query_tree().children
+    except Exception:
+        top = []
+    for w in top:
+        try:
+            g = w.get_geometry()
+            if w.get_attributes().map_state == 2 and min(g.width, g.height) <= 20 and (g.width, g.height) != (1, 1):
+                w.unmap()
+        except Exception:
+            pass
+    d.sync()
+    d.close()
+
+
+def start_we(first):
+    global we_proc, log
+    subprocess.run(['/usr/lib/wine/wineserver', '-k'], env=env, stderr=subprocess.DEVNULL)
+    time.sleep(1)
+    log = open(S + '/we_run_last.log', 'w')
+    we_proc = subprocess.Popen([WINE, 'wallpaper64.exe', '-control', 'openWallpaper', '-file', winpath(first),
+                                '-monitor', '0', '-playInWindow', '-silent'], cwd=S + '/we28', env=env,
+                               stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
+
+
+def wait_rendered(m, t0, timeout):
+    """until the window isn't a flat colour any more, or timeout"""
+    while time.time() - t0 < timeout:
+        hide_ui()
+        if small(grab(m)).std() > 3:
+            return True
+        time.sleep(0.3)
+    return False
+
+
+def item_file(id_, info):
+    if info.get('type') == 'scene':
+        # "file" is scene.json, or gifscene.json for gif scenes, packed into the .pkg of the same name
+        name = info.get('file') or 'scene.json'
+        pkg = os.path.join(W, id_, os.path.splitext(name)[0] + '.pkg')
+        return pkg if os.path.exists(pkg) else os.path.join(W, id_, name)
+    return os.path.join(W, id_, 'project.json')
+
+
+def main():
+    import json, glob
+    db = json.load(open(LIB + '/features.json'))
+    ids = sys.argv[1:] or sorted(db)
+    if not os.environ.get('FORCE'):
+        ids = [i for i in ids if os.path.exists(i) or not glob.glob(f'{REFS}/{i}/f*.png')]
+    out_root = REFS
+    files = []
+    for n, i in enumerate(ids):
+        if os.path.exists(i):
+            path = os.path.abspath(i)
+            files.append(path + '/scene.json' if os.path.isdir(path) else path)
+            ids[n] = os.path.basename(path if os.path.isdir(path) else os.path.dirname(path))
+        else:
+            files.append(item_file(i, db.get(i, {}).get('info', {})))
+    subprocess.run(['bash', HERE + '/we_display.sh'], stdout=subprocess.DEVNULL, check=True)
+    with mss.MSS(display=D) as m:
+        for n, f in enumerate(files):
+            id_ = ids[n]
+            out = os.path.join(out_root, id_)
+            os.makedirs(out, exist_ok=True)
+            for old in os.listdir(out):
+                os.remove(os.path.join(out, old))
+            t0 = time.time()
+            # scenes with video textures sometimes never get past a flat first frame under Wine, a new
+            # instance usually does
+            for attempt in range(RETRIES):
+                start = time.time()
+                start_we(f)
+                # the killed instance's window must be gone before "not flat" means anything
+                while time.time() - start < 10 and small(grab(m)).std() > 3:
+                    time.sleep(0.2)
+                ok = wait_rendered(m, start, TIMEOUT)
+                if ok:
+                    break
+            time.sleep(SETTLE)
+            hide_ui()
+            t1 = time.time()
+            last = None
+            for i in range(FRAMES):
+                last = grab(m)
+                last.save('%s/f%03d_%.1f.png' % (out, i, time.time() - t1))
+                time.sleep(INTERVAL)
+            log.flush()
+            try:
+                with open(S + '/we_run_last.log', errors='replace') as src, open(out + '/we.log', 'w') as dst:
+                    dst.write(src.read()[-20000:])
+            except OSError:
+                pass
+            print(f'we {id_} {FRAMES} frames{"" if ok else " (still flat)"} {time.time() - t0:.1f}s', flush=True)
+    subprocess.run(['/usr/lib/wine/wineserver', '-k'], env=env, stderr=subprocess.DEVNULL)
+
+
+if __name__ == '__main__':
+    main()

+ 335 - 0
tools/wecompare/scan.py

@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+"""Scans the workshop library and writes a feature inventory.
+
+    scan.py [ids...]        (no ids = whole library)
+
+Writes features.json (per item: info + feature counter) and FEATURES.md (feature -> items index,
+for picking regression targets) into $LWE_LIBRARY_DIR (default ~/.local/share/lwe-library).
+Reads scene.pkg in memory, nothing unpacked.
+"""
+import collections, json, os, re, struct, sys
+
+W = os.environ.get('LWE_WORKSHOP_DIR', '/workspace/SteamLibrary/steamapps/workshop/content/431960')
+ASSETS = os.environ.get('LWE_ASSETS_DIR', '/workspace/SteamLibrary/steamapps/common/wallpaper_engine/assets')
+HERE = os.environ.get('LWE_LIBRARY_DIR', os.path.expanduser('~/.local/share/lwe-library'))
+
+
+def parse_json(data):
+    if isinstance(data, bytes):
+        data = data.decode('utf-8-sig', errors='replace')
+    data = re.sub(r',(\s*[}\]])', r'\1', data)
+    try:
+        return json.loads(data)
+    except Exception:
+        return None
+
+
+class Source:
+    """Files of one item: scene.pkg entries, then loose files, then its dependency, then assets."""
+
+    def __init__(self, root, dep_root=None):
+        self.roots = [root] + ([dep_root] if dep_root else []) + [ASSETS]
+        self.pkgs = []
+        for r in self.roots[:-1]:
+            for name in ('scene.pkg', 'gifscene.pkg'):
+                p = os.path.join(r, name)
+                if os.path.exists(p):
+                    self.pkgs.append(self._index(p))
+
+    @staticmethod
+    def _index(path):
+        with open(path, 'rb') as f:
+            def u32():
+                return struct.unpack('<I', f.read(4))[0]
+            f.read(u32())
+            entries = {}
+            for _ in range(u32()):
+                name = f.read(u32()).decode('utf-8', errors='replace').replace('\\', '/')
+                entries[name] = (u32(), u32())
+            return path, f.tell(), entries
+
+    def names(self):
+        out = set()
+        for _, _, e in self.pkgs:
+            out.update(e)
+        for r in self.roots[:1]:
+            for dp, _, fs in os.walk(r):
+                for f in fs:
+                    out.add(os.path.relpath(os.path.join(dp, f), r))
+        return out
+
+    def read(self, rel, limit=None):
+        if not isinstance(rel, str):
+            return None
+        rel = rel.replace('\\', '/')
+        for path, base, entries in self.pkgs:
+            if rel in entries:
+                off, ln = entries[rel]
+                with open(path, 'rb') as f:
+                    f.seek(base + off)
+                    return f.read(ln if limit is None else min(ln, limit))
+        for r in self.roots:
+            p = os.path.join(r, rel)
+            if os.path.isfile(p):
+                with open(p, 'rb') as f:
+                    return f.read(limit if limit else -1)
+        return None
+
+    def json(self, rel):
+        d = self.read(rel)
+        return parse_json(d) if d is not None else None
+
+
+def val(v):
+    return v.get('value') if isinstance(v, dict) else v
+
+
+def truthy(v):
+    v = val(v)
+    return v not in (None, False, 0, '', '0')
+
+
+def scan_scene(src, out, info):
+    name = info.get('file') if info.get('type') == 'scene' and info.get('file') else 'scene.json'
+    if name != 'scene.json':
+        out['scenefile:' + name] += 1
+    sc = src.json(name)
+    if sc is None:
+        out['ERROR:no-' + name] += 1
+        return
+    info['version'] = sc.get('version')
+    g = sc.get('general', {})
+    for k in ('bloom', 'hdr', 'cameraparallax', 'camerashake', 'fog', 'fogheight',
+              'reflection', 'lightconfig'):
+        if truthy(g.get(k)):
+            out['general:' + k] += 1
+    if g.get('orthogonalprojection') is None:
+        out['3d-perspective'] += 1
+    else:
+        op = g['orthogonalprojection']
+        if isinstance(op, dict) and op.get('auto'):
+            out['ortho-auto'] += 1
+        elif isinstance(op, dict):
+            info['size'] = f"{op.get('width')}x{op.get('height')}"
+    if sc.get('camera', {}).get('paths'):
+        out['camera-paths'] += 1
+    objs = sc.get('objects', [])
+    info['objs'] = len(objs)
+    ids = {o.get('id') for o in objs}
+    for o in objs:
+        blob = json.dumps(o, ensure_ascii=False)
+        if o.get('visible') is False or (isinstance(o.get('visible'), dict) and o['visible'].get('value') is False):
+            out['invisible-obj'] += 1
+        if isinstance(o.get('visible'), dict) and o['visible'].get('user'):
+            out['user-visible-toggle'] += 1
+        if o.get('parent') is not None and o.get('parent') in ids:
+            out['parented'] += 1
+        if 'image' in o:
+            img = o['image'] or ''
+            model = src.json(img) if img else None
+            if img.startswith('models/util/solidlayer'):
+                out['solidlayer'] += 1
+            elif 'fullscreenlayer' in img:
+                out['fullscreenlayer'] += 1
+            elif 'composelayer' in img:
+                out['composelayer'] += 1
+            elif model and model.get('puppet'):
+                out['puppet'] += 1
+            else:
+                out['image'] += 1
+            if model and model.get('autosize'):
+                out['autosize'] += 1
+            if model and model.get('material'):
+                mat = src.json(model['material'])
+                if mat:
+                    for p in mat.get('passes', []):
+                        for t in p.get('textures', []) or []:
+                            if t and isinstance(t, str) and t.startswith('_rt_'):
+                                out['material-rt-texture'] += 1
+                        if p.get('usertextures'):
+                            out['usertexture'] += 1
+            if truthy(o.get('perspective')):
+                out['image-3d-perspective'] += 1
+        if 'model' in o:
+            out['model3d'] += 1
+        for k in ('text', 'particle', 'sound', 'light'):
+            if k in o:
+                out[k if k != 'light' else 'light:' + str(o.get('light'))] += 1
+        if 'camera' in o or o.get('type') == 'camera':
+            out['camera-object'] += 1
+        if 'particle' in o:
+            p = src.json(o['particle']) or {}
+            if p.get('children'):
+                out['particle-children'] += 1
+            if 'rope' in json.dumps(p.get('renderers', [])):
+                out['particle-rope'] += 1
+            for sect in ('operators', 'initializers', 'emitters'):
+                for x in p.get(sect, []) or []:
+                    n = x.get('name')
+                    if n in ('boids', 'collisionplane', 'collisionsphere', 'collisionbox', 'collisionquad', 'remapvalue',
+                             'maintaindistancetocontrolpoint', 'maintaindistancebetweencontrolpoints', 'controlpointattract',
+                             'vortex', 'vortex_v2', 'turbulence', 'audioprocessing', 'hsvcolorrandom', 'colorlist',
+                             'capvelocity', 'reducemovementnearcontrolpoint', 'inheritvaluefromevent', 'mapsequencebetweencontrolpoints',
+                             'mapsequencearoundcontrolpoint', 'boxrandom', 'sphererandom'):
+                        out['pcomp:' + n] += 1
+            if o.get('instanceoverride'):
+                out['particle-instanceoverride'] += 1
+        if o.get('copybackground') is False:
+            out['copybackground=false'] += 1
+        if o.get('passthrough'):
+            out['passthrough'] += 1
+        if o.get('solid') is not None or o.get('disablepropagation'):
+            out['cursor-solid/propagation'] += 1
+        pd = o.get('parallaxDepth')
+        if isinstance(pd, str) and pd.strip() not in ('0 0', '1 1', '0.00000 0.00000', '1.00000 1.00000'):
+            out['parallaxDepth'] += 1
+        if val(o.get('colorBlendMode')) not in (None, 0):
+            out['colorBlendMode'] += 1
+        n = blob.count('"script"')
+        if n:
+            out['scripts'] += n
+            if 'createLayer' in blob:
+                out['script:createLayer'] += 1
+            if 'getVideoTexture' in blob:
+                out['script:videoTexture'] += 1
+            if 'registerAudioBuffers' in blob:
+                out['script:audio'] += 1
+            if 'cursor' in blob.lower():
+                out['script:cursor'] += 1
+            if 'mediaPlayback' in blob or 'mediaThumbnail' in blob or 'mediaProperties' in blob:
+                out['script:media'] += 1
+        if '"animation"' in blob:
+            out['prop-animation'] += 1
+        if 'animationlayers' in blob:
+            out['puppet-animationlayers'] += 1
+        if '"user"' in blob:
+            out['user-bound-values'] += 1
+        for e in o.get('effects', []) or []:
+            f = e.get('file', '')
+            m = re.match(r'effects/(workshop/\d+/)?([^/]+)/', f)
+            name = m.group(2) if m else f
+            if m and m.group(1):
+                out['workshop-effect'] += 1
+                name = 'ws:' + name
+            elif src.read(f, 1) is not None and not os.path.exists(os.path.join(ASSETS, f)):
+                name += '*'
+            out['fx:' + name] += 1
+            if isinstance(e.get('visible'), dict) and e['visible'].get('user'):
+                out['user-effect-toggle'] += 1
+    names = src.names()
+    shaders = [n for n in names if n.startswith('shaders/') and n.endswith(('.frag', '.vert'))]
+    if shaders:
+        out['custom-shaders'] = len(shaders)
+    for d in ('videos', 'sounds', 'fonts', 'scripts'):
+        n = len([x for x in names if x.startswith(d + '/')])
+        if n:
+            out['dir:' + d] = n
+    vt = gif = 0
+    for n in names:
+        if n.startswith('materials/') and n.endswith('.tex'):
+            head = src.read(n, 4096) or b''
+            if b'ftyp' in head:
+                vt += 1
+            elif head[:9] == b'TEXV0005\x00' and b'TEXS' in head[:200]:
+                gif += 1
+    if vt:
+        out['video-tex'] = vt
+
+
+def scan_web(root, out):
+    for dp, _, fs in os.walk(root):
+        for f in fs:
+            if f.endswith(('.js', '.html', '.htm')):
+                try:
+                    t = open(os.path.join(dp, f), encoding='utf-8', errors='replace').read()
+                except OSError:
+                    continue
+                for k in ('wallpaperRegisterAudioListener', 'wallpaperPropertyListener', 'wallpaperRegisterMediaPropertiesListener',
+                          'wallpaperRegisterMediaThumbnailListener', 'wallpaperRegisterMediaPlaybackListener', 'webgl', 'WebGL',
+                          'THREE.', 'PIXI', 'createjs', 'AudioContext', 'requestAnimationFrame', '<video', 'localStorage'):
+                    if k in t:
+                        out['web:' + k.strip('<.').lower()] += 1
+    out.update({k: 1 for k in list(out) if k.startswith('web:')})
+
+
+def scan(id_):
+    root = os.path.join(W, id_)
+    proj = parse_json(open(os.path.join(root, 'project.json'), 'rb').read()) or {}
+    info = {'title': proj.get('title'), 'type': (proj.get('type') or '').lower(), 'dep': proj.get('dependency'),
+            'file': proj.get('file'), 'rating': proj.get('contentrating')}
+    if not info['type'] and proj.get('dependency'):
+        info['type'] = 'preset'
+    out = collections.Counter()
+    dep_root = os.path.join(W, proj['dependency']) if proj.get('dependency') else None
+    if dep_root and not os.path.isdir(dep_root):
+        out['ERROR:missing-dependency'] += 1
+        dep_root = None
+    if info['type'] == 'scene' or (info['type'] == 'preset' and dep_root):
+        if info['type'] == 'preset':
+            out['preset'] += 1
+            dp = parse_json(open(os.path.join(dep_root, 'project.json'), 'rb').read()) or {}
+            info['dep_type'] = (dp.get('type') or '').lower()
+        if info['type'] == 'scene' or info.get('dep_type') == 'scene':
+            scan_scene(Source(root, dep_root), out, info)
+    elif info['type'] == 'web':
+        scan_web(root, out)
+    elif info['type'] == 'video':
+        f = proj.get('file') or ''
+        out['video:' + os.path.splitext(f)[1].lower()] += 1
+    props = (proj.get('general') or {}).get('properties') or {}
+    info['props'] = len(props)
+    for p in props.values():
+        if isinstance(p, dict) and p.get('type') in ('usershortcut', 'file', 'directory', 'scenetexture', 'textinput', 'combo'):
+            out['prop:' + p['type']] += 1
+    if (proj.get('general') or {}).get('supportsaudioprocessing'):
+        out['supportsaudioprocessing'] += 1
+    if proj.get('preset'):
+        out['has-preset-values'] += 1
+    try:
+        info['size_mb'] = round(sum(os.path.getsize(os.path.join(dp, f)) for dp, _, fs in os.walk(root) for f in fs) / 1e6, 1)
+        info['mtime'] = int(max(os.path.getmtime(os.path.join(root, f)) for f in os.listdir(root)))
+    except (OSError, ValueError):
+        pass
+    return info, out
+
+
+def main():
+    os.makedirs(HERE, exist_ok=True)
+    path = os.path.join(HERE, 'features.json')
+    db = json.load(open(path)) if os.path.exists(path) else {}
+    ids = sys.argv[1:] or sorted(x for x in os.listdir(W) if os.path.exists(os.path.join(W, x, 'project.json')))
+    if not sys.argv[1:]:
+        for gone in set(db) - set(ids):
+            print('removed from library:', gone, db[gone]['info'].get('title'))
+            del db[gone]
+    for id_ in ids:
+        try:
+            info, feats = scan(id_)
+        except Exception as e:
+            info, feats = {'title': '?', 'type': '?'}, collections.Counter({'ERROR:scan ' + type(e).__name__: 1})
+        if id_ not in db:
+            print('new:', id_, info.get('title'))
+        db[id_] = {'info': info, 'features': dict(sorted(feats.items()))}
+    json.dump(db, open(path, 'w'), indent=1, ensure_ascii=False, sort_keys=True)
+
+    index = collections.defaultdict(list)
+    for id_, d in db.items():
+        for k in d['features']:
+            index[re.sub(r'\*$', '', k)].append(id_)
+    types = collections.Counter(d['info'].get('type') for d in db.values())
+    with open(os.path.join(HERE, 'FEATURES.md'), 'w') as f:
+        f.write(f'# Library features (generated by scan.py, {len(db)} items: '
+                + ', '.join(f'{v} {k}' for k, v in types.most_common()) + ')\n\n')
+        f.write('## Feature -> items (rarest first; `fx:name*` in features.json = effect shipped by the wallpaper)\n\n')
+        for k, v in sorted(index.items(), key=lambda kv: (len(kv[1]), kv[0])):
+            f.write(f'- **{k}** ({len(v)}): {" ".join(sorted(v))}\n')
+        f.write('\n## Items\n\n')
+        for id_, d in sorted(db.items()):
+            i = d['info']
+            extra = ' '.join(f'{k}={i[k]}' for k in ('version', 'objs', 'size', 'dep') if i.get(k) is not None)
+            f.write(f'- {id_} [{i.get("type")}] {i.get("title")} ({extra})\n  '
+                    + ', '.join(f'{k}{"x" + str(v) if v > 1 else ""}' for k, v in d['features'].items()) + '\n')
+
+
+if __name__ == '__main__':
+    main()

+ 113 - 0
tools/wecompare/wine/qrenderdoc_analytics.json

@@ -0,0 +1,113 @@
+{
+    "APIs": [
+    ],
+    "Analytics": 1,
+    "CaptureFeatures": {
+        "CustomAnnotations": false,
+        "D3D12Bundle": false,
+        "DXILShaders": false,
+        "MultiGPU": false,
+        "ShaderLinkage": false,
+        "SparseResources": false,
+        "YUVTextures": false
+    },
+    "Date": {
+        "Month": 9,
+        "Year": 2026
+    },
+    "Export": {
+        "EventBrowser": false,
+        "MeshOutput": false,
+        "PipelineState": false,
+        "RawBuffer": false,
+        "Shader": false,
+        "Texture": false
+    },
+    "GPUVendors": [
+    ],
+    "Metadata": {
+        "Bitness": 64,
+        "DaysUsed": [
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            false,
+            true,
+            false,
+            false,
+            false,
+            false
+        ],
+        "DevelBuildRun": false,
+        "DistributionVersion": "",
+        "OSVersion": "Debian GNU/Linux 13 (trixie)",
+        "OfficialBuildRun": true,
+        "RenderDocVersion": "v1.45"
+    },
+    "Performance": {
+        "LoadTime": {
+            "Count": 0,
+            "Total": 0
+        }
+    },
+    "ShaderDebug": {
+        "Compute": false,
+        "Pixel": false,
+        "Vertex": false
+    },
+    "TextureOverlays": {
+        "BackfaceCull": false,
+        "ClearBeforeDraw": false,
+        "ClearBeforePass": false,
+        "Clipping": false,
+        "Depth": false,
+        "Drawcall": false,
+        "NaN": false,
+        "QuadOverdrawDraw": false,
+        "QuadOverdrawPass": false,
+        "Stencil": false,
+        "TriangleSizeDraw": false,
+        "TriangleSizePass": false,
+        "ViewportScissor": false,
+        "Wireframe": false
+    },
+    "UIFeatures": {
+        "AndroidRemoteReplay": false,
+        "Bookmarks": false,
+        "CallstackResolve": false,
+        "CaptureComments": false,
+        "CustomTextureVisualise": false,
+        "DrawcallTimes": false,
+        "ImageViewer": false,
+        "NonAndroidRemoteReplay": false,
+        "PerformanceCounters": false,
+        "PixelHistory": false,
+        "PythonInterop": true,
+        "ResourceInspect": false,
+        "ShaderEditing": false
+    },
+    "Version": 1
+}

+ 29 - 0
tools/wecompare/wine/rd_trigger.py

@@ -0,0 +1,29 @@
+# qrenderdoc --python rd_trigger.py: one-frame capture of the RenderDoc-hooked process RD_PID, path written to RD_TRIGGER_OUT
+import os, time, renderdoc as rd
+log = open(os.environ['RD_TRIGGER_OUT'], 'w', buffering=1)
+pid = int(os.environ['RD_PID'])
+ident = 0
+while True:
+    ident = rd.EnumerateRemoteTargets('localhost', ident)
+    if ident == 0:
+        break
+    tc = rd.CreateTargetControl('localhost', ident, 'we_live', True)
+    if tc is None:
+        continue
+    if tc.GetPID() != pid:
+        tc.Shutdown()
+        continue
+    tc.TriggerCapture(1)
+    deadline = time.time() + 40
+    while time.time() < deadline:
+        msg = tc.ReceiveMessage(None)
+        if msg.type == rd.TargetControlMessageType.NewCapture:
+            log.write(msg.newCapture.path + '\n')
+            break
+        if msg.type == rd.TargetControlMessageType.Disconnected:
+            log.write('disconnected\n')
+            break
+    tc.Shutdown()
+    break
+log.write('done\n')
+os._exit(0)

+ 22 - 0
tools/wecompare/wine/rdscripts/rd_actions.py

@@ -0,0 +1,22 @@
+import os, renderdoc as rd
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+texs={t.resourceId:t for t in ctl.GetTextures()}
+names={r.resourceId:r.name for r in ctl.GetResources()}
+out.write('TEXTURES mips>1:\n')
+for t in texs.values():
+    if t.mips>1 and t.width>=256: out.write('  %s %s %dx%d mips=%d fmt=%s\n'%(t.resourceId,names.get(t.resourceId),t.width,t.height,t.mips,t.format.Name()))
+def walk(a,d=0):
+    for c in a:
+        flags=c.flags
+        outs=[str(o) for o in c.outputs if o!=rd.ResourceId.Null()]
+        nm=c.GetName(ctl.GetStructuredFile())
+        if not (flags & rd.ActionFlags.Drawcall) or os.environ.get('ALL'):
+            out.write('%s%d %s outs=%s copySrc=%s copyDst=%s\n'%('  '*d,c.eventId,nm,outs,c.copySource,c.copyDestination))
+        else:
+            out.write('%s%d DRAW %s n=%d outs=%s\n'%('  '*d,c.eventId,nm,c.numIndices,outs))
+        walk(c.children,d+1)
+walk(ctl.GetRootActions())
+ctl.Shutdown(); cap.Shutdown()
+out.close(); os._exit(0)

+ 23 - 0
tools/wecompare/wine/rdscripts/rd_cbraw.py

@@ -0,0 +1,23 @@
+import os, struct, renderdoc as rd
+# raw float dump of every constant buffer bound to VS/GS/PS for EIDS (names are stripped in WE's DXBC)
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+for eid in [int(x) for x in os.environ['EIDS'].split(',')]:
+    ctl.SetFrameEvent(eid,True)
+    s=ctl.GetPipelineState()
+    out.write('== %d\n'%eid)
+    for stage in (rd.ShaderStage.Vertex, rd.ShaderStage.Geometry, rd.ShaderStage.Pixel):
+        refl=s.GetShaderReflection(stage)
+        if refl is None: continue
+        for i,cb in enumerate(refl.constantBlocks):
+            d=s.GetConstantBlock(stage,i,0).descriptor
+            if d.resource==rd.ResourceId.Null(): continue
+            data=ctl.GetBufferData(d.resource,d.byteOffset,min(d.byteSize,1024))
+            fl=struct.unpack('<%df'%(len(data)//4),data[:len(data)//4*4])
+            out.write(' %s cb%d %s size=%d\n'%(stage,i,cb.name,d.byteSize))
+            for r in range(0,len(fl),4): out.write('   [%2d] %s\n'%(r//4,' '.join('%.6g'%x for x in fl[r:r+4])))
+        if os.environ.get('DIS') and stage==getattr(rd.ShaderStage,os.environ.get('DIS')):
+            t=ctl.DisassembleShader(s.GetGraphicsPipelineObject(),refl,'')
+            out.write(t[:6000]+'\n')
+ctl.Shutdown(); cap.Shutdown(); out.close(); os._exit(0)

+ 20 - 0
tools/wecompare/wine/rdscripts/rd_draws.py

@@ -0,0 +1,20 @@
+import os, renderdoc as rd
+# every draw with its index count, render targets and fragment textures (id WxH format)
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+texs={t.resourceId:t for t in ctl.GetTextures()}
+def desc(r):
+    t=texs.get(r)
+    return '%s(%dx%d %s)'%(int(r),t.width,t.height,t.format.Name()) if t else str(int(r))
+def walk(a):
+    for c in a:
+        if c.flags & rd.ActionFlags.Drawcall:
+            ctl.SetFrameEvent(c.eventId,True)
+            s=ctl.GetPipelineState()
+            ro=[desc(x.descriptor.resource) for x in s.GetReadOnlyResources(rd.ShaderStage.Fragment)]
+            outs=[desc(o) for o in c.outputs if o!=rd.ResourceId.Null()]
+            out.write('%d n=%d inst=%d outs=%s tex=%s\n'%(c.eventId,c.numIndices,c.numInstances,outs,ro))
+        walk(c.children)
+walk(ctl.GetRootActions())
+ctl.Shutdown(); cap.Shutdown(); out.close(); os._exit(0)

+ 30 - 0
tools/wecompare/wine/rdscripts/rd_frag.py

@@ -0,0 +1,30 @@
+import os, renderdoc as rd
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+names={r.resourceId:r.name for r in ctl.GetResources()}
+for eid in [int(x) for x in os.environ['EIDS'].split(',')]:
+    ctl.SetFrameEvent(eid,True)
+    s=ctl.GetPipelineState()
+    out.write('== %d\n'%eid)
+    for stage in (rd.ShaderStage.Vertex, rd.ShaderStage.Fragment):
+        refl=s.GetShaderReflection(stage)
+        if refl is None: continue
+        for ro in s.GetReadOnlyResources(stage):
+            out.write('  %s tex slot=%d %s\n'%(stage,ro.access.index,ro.descriptor.resource))
+        for i,cb in enumerate(refl.constantBlocks):
+            b=s.GetConstantBlock(stage,i,0); d=b.descriptor
+            try:
+                vs=ctl.GetCBufferVariableContents(s.GetGraphicsPipelineObject(), s.GetShader(stage), stage, refl.entryPoint, i, d.resource, d.byteOffset, d.byteSize)
+            except Exception as e:
+                out.write('   cb err %s\n'%e); continue
+            def dump(v,p=''):
+                if v.members:
+                    for m in v.members: dump(m,p+v.name+'.')
+                else:
+                    out.write('   %s%s %s = %s\n'%(stage==rd.ShaderStage.Vertex and 'V:' or 'F:',p,v.name,list(v.value.f32v[:v.rows*v.columns])))
+            for v in vs: dump(v)
+    if os.environ.get('SRC'):
+        refl=s.GetShaderReflection(rd.ShaderStage.Fragment)
+        out.write('   frag entry %s\n'%refl.entryPoint)
+ctl.Shutdown(); cap.Shutdown(); out.close(); os._exit(0)

+ 14 - 0
tools/wecompare/wine/rdscripts/rd_samp.py

@@ -0,0 +1,14 @@
+import os, renderdoc as rd
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+for eid in [int(x) for x in os.environ['EIDS'].split(',')]:
+    ctl.SetFrameEvent(eid,True)
+    s=ctl.GetPipelineState()
+    for sm in s.GetSamplers(rd.ShaderStage.Fragment):
+        d=sm.sampler
+        out.write('%d slot=%d filter=%s/%s/%s addr=%s %s lod=[%s,%s] bias=%s aniso=%s\n'%(eid,sm.access.index,d.filter.minify,d.filter.magnify,d.filter.mip,d.addressU,d.addressV,d.minLOD,d.maxLOD,d.mipBias,d.maxAnisotropy))
+    for ro in s.GetReadOnlyResources(rd.ShaderStage.Fragment):
+        dd=ro.descriptor
+        out.write('   tex slot=%d res=%s firstMip=%d numMips=%d\n'%(ro.access.index,dd.resource,dd.firstMip,dd.numMips))
+ctl.Shutdown(); cap.Shutdown(); out.close(); os._exit(0)

+ 10 - 0
tools/wecompare/wine/rdscripts/rd_save.py

@@ -0,0 +1,10 @@
+import os, renderdoc as rd
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+for spec in os.environ['SAVE'].split(','):
+    eid,res,mip,out=spec.split(':')
+    ctl.SetFrameEvent(int(eid),True)
+    rid=[t.resourceId for t in ctl.GetTextures() if int(t.resourceId)==int(res)][0]
+    s=rd.TextureSave(); s.resourceId=rid; s.mip=int(mip); s.destType=rd.FileType.PNG; s.alpha=rd.AlphaMapping.Preserve
+    ctl.SaveTexture(s,out)
+ctl.Shutdown(); cap.Shutdown(); os._exit(0)

+ 13 - 0
tools/wecompare/wine/rdscripts/rd_vtx.py

@@ -0,0 +1,13 @@
+import os, renderdoc as rd, struct
+out=open(os.environ['OUT'],'w')
+cap=rd.OpenCaptureFile(); cap.OpenFile(os.environ['CAP'],'',None)
+st,ctl=cap.OpenCapture(rd.ReplayOptions(),None)
+for eid in [int(x) for x in os.environ['EIDS'].split(',')]:
+    ctl.SetFrameEvent(eid,True)
+    post=ctl.GetPostVSData(0,0,rd.MeshDataStage.VSOut)
+    data=ctl.GetBufferData(post.vertexResourceId, post.vertexByteOffset, post.vertexByteStride*post.numIndices)
+    out.write('== %d n=%d stride=%d\n'%(eid,post.numIndices,post.vertexByteStride))
+    for i in range(post.numIndices):
+        v=struct.unpack_from('4f',data,i*post.vertexByteStride)
+        out.write('  %s\n'%(v,))
+ctl.Shutdown(); cap.Shutdown(); out.close(); os._exit(0)

+ 48 - 0
tools/wecompare/wine/renderdoc_capture.json

@@ -0,0 +1,48 @@
+{
+  "file_format_version" : "1.1.2",
+  "layer" : {
+    "name": "VK_LAYER_RENDERDOC_Capture",
+    "type": "GLOBAL",
+    "library_path": "/opt/renderdoc/lib/librenderdoc.so",
+    "api_version": "1.4.324",
+    "implementation_version": "45",
+    "description": "Debugging capture layer for RenderDoc",
+    "functions": {
+      "vkGetInstanceProcAddr": "VK_LAYER_RENDERDOC_CaptureGetInstanceProcAddr",
+      "vkGetDeviceProcAddr": "VK_LAYER_RENDERDOC_CaptureGetDeviceProcAddr",
+      "vkNegotiateLoaderLayerInterfaceVersion": "VK_LAYER_RENDERDOC_CaptureNegotiateLoaderLayerInterfaceVersion"
+    },
+    "pre_instance_functions": {
+      "vkEnumerateInstanceExtensionProperties": "VK_LAYER_RENDERDOC_CaptureEnumerateInstanceExtensionProperties"
+    },
+    "instance_extensions": [
+      {
+        "name": "VK_EXT_debug_utils",
+        "spec_version": "1"
+      }
+    ],
+    "device_extensions": [
+      {
+        "name": "VK_EXT_debug_marker",
+        "spec_version": "4",
+        "entrypoints": ["vkDebugMarkerSetObjectTagEXT",
+                        "vkDebugMarkerSetObjectNameEXT",
+                        "vkCmdDebugMarkerBeginEXT",
+                        "vkCmdDebugMarkerEndEXT",
+                        "vkCmdDebugMarkerInsertEXT"
+                       ]
+      },
+      {
+        "name": "VK_EXT_tooling_info",
+        "spec_version": "1",
+        "entrypoints": ["vkGetPhysicalDeviceToolPropertiesEXT"]
+      }
+    ],
+    "enable_environment": {
+      "ENABLE_VULKAN_RENDERDOC_CAPTURE": "1"
+    },
+    "disable_environment": {
+      "DISABLE_VULKAN_RENDERDOC_CAPTURE_1_45": "1"
+    }
+  }
+}

+ 18 - 0
tools/wecompare/wine/repack_pkg.py

@@ -0,0 +1,18 @@
+import struct,sys,json
+# repack.py in.pkg out.pkg name=replacementfile ...
+src,dst=sys.argv[1],sys.argv[2]
+repl=dict(a.split('=',1) for a in sys.argv[3:])
+f=open(src,'rb');r=lambda:struct.unpack('<I',f.read(4))[0]
+hl=r();header=f.read(hl);n=r();ents=[]
+for _ in range(n):
+    nl=r();name=f.read(nl);o=r();l=r();ents.append((name,o,l))
+base=f.tell();data=[]
+for name,o,l in ents:
+    k=name.decode('utf-8','replace')
+    if k in repl: data.append((name,open(repl[k],'rb').read()))
+    else: f.seek(base+o); data.append((name,f.read(l)))
+out=open(dst,'wb');w=lambda v:out.write(struct.pack('<I',v))
+w(len(header));out.write(header);w(len(data));off=0
+for name,b in data:
+    w(len(name));out.write(name);w(off);w(len(b));off+=len(b)
+for _,b in data: out.write(b)

+ 25 - 0
tools/wecompare/wine/we_capture.sh

@@ -0,0 +1,25 @@
+#!/bin/bash
+# usage: we_capture.sh <scene dir or scene.json/scene.pkg> [seconds before capture]
+# RenderDoc capture of one frame of real WE 2.8.42 (DXVK -> Vulkan on the GPU, isolated display :98).
+# Prints the .rdc path; inspect it with tools/rdc_dump.py using DISPLAY=:98 (Xvfb has no DRI3, replay output asserts there)
+S=${WE_LIVE_DIR:-$HOME/.local/share/we_live}
+bash "$(dirname "$0")/we_display.sh" >/dev/null || exit 1
+pgrep -f "Xvfb :97" >/dev/null || { Xvfb :97 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; }
+export WINEPREFIX=$S/wineprefix DISPLAY=:98 WINEDEBUG=-all DXVK_LOG_PATH=$S
+# Wine must only see the isolated :98, not the desktop session (wayland-0, D-Bus) in the shared runtime dir
+unset WAYLAND_DISPLAY WAYLAND_SOCKET DBUS_SESSION_BUS_ADDRESS; export XDG_RUNTIME_DIR=$S/run
+# keep Wine off the desktop's audio server (a shared /run/user/<uid>/pulse would play the wallpaper's sounds on real speakers and feed real system audio to the visualizers)
+export PULSE_SERVER=unix:$S/run/no-pulse PIPEWIRE_REMOTE=$S/run/no-pipewire ALSA_CONFIG_PATH=/dev/null
+export VK_ADD_IMPLICIT_LAYER_PATH=$S/vklayer ENABLE_VULKAN_RENDERDOC_CAPTURE=1
+/usr/lib/wine/wineserver -k 2>/dev/null; sleep 3
+cd $S/we28
+F=$1; [ -d "$F" ] && F=$F/scene.json
+WIN=$(echo "Z:$F" | tr '/' '\\')
+nohup /usr/lib/wine/wine64 wallpaper64.exe -control openWallpaper -file "$WIN" -monitor 0 -playInWindow -silent > $S/we_run_last.log 2>&1 &
+sleep ${2:-30}
+PID=$(pgrep -f "wallpaper64.exe -control" | head -1)
+# qrenderdoc only has the xcb platform; it runs on the isolated Xvfb :97 (analytics already opted out in ~/.renderdoc)
+env -u WAYLAND_DISPLAY -u VK_ADD_IMPLICIT_LAYER_PATH -u ENABLE_VULKAN_RENDERDOC_CAPTURE RD_PID=$PID RD_TRIGGER_OUT=$S/rd_trigger.out \
+    QT_QPA_PLATFORM=xcb DISPLAY=:97 timeout 90 qrenderdoc --python $S/rd_trigger.py >/dev/null 2>&1
+/usr/lib/wine/wineserver -k 2>/dev/null
+head -1 $S/rd_trigger.out

+ 47 - 0
tools/wecompare/wine/we_cursor.sh

@@ -0,0 +1,47 @@
+#!/bin/bash
+# usage: we_cursor.sh <scene dir or scene.json/scene.pkg> <out dir> "x,y x,y ..." [settle seconds]
+# like we_full.sh, but warps the pointer (screen px on :98) before each grab: f<i>_<x>_<y>.png
+S=${WE_LIVE_DIR:-$HOME/.local/share/we_live}
+D=:98
+bash "$(dirname "$0")/we_display.sh" >/dev/null || exit 1
+export WINEPREFIX=$S/wineprefix DISPLAY=$D WINEDEBUG=-all DXVK_LOG_PATH=$S
+# Wine must only see the isolated :98, not the desktop session (wayland-0, D-Bus) in the shared runtime dir
+unset WAYLAND_DISPLAY WAYLAND_SOCKET DBUS_SESSION_BUS_ADDRESS; export XDG_RUNTIME_DIR=$S/run
+# keep Wine off the desktop's audio server (a shared /run/user/<uid>/pulse would play the wallpaper's sounds on real speakers and feed real system audio to the visualizers)
+export PULSE_SERVER=unix:$S/run/no-pulse PIPEWIRE_REMOTE=$S/run/no-pipewire ALSA_CONFIG_PATH=/dev/null
+/usr/lib/wine/wineserver -k 2>/dev/null; timeout 4 tail -f /dev/null
+cd $S/we28
+F=$1; [ -d "$F" ] && F=$F/scene.json
+WIN=$(echo "Z:$F" | tr '/' '\\')
+nohup /usr/lib/wine/wine64 wallpaper64.exe -control openWallpaper -file "$WIN" -monitor 0 -playInWindow -silent > $S/we_run_last.log 2>&1 &
+timeout 25 tail -f /dev/null
+mkdir -p "$2"
+python3 - "$2" "$3" "${4:-3}" "$D" <<'PY'
+import sys, time, mss
+from PIL import Image
+from Xlib import display
+from Xlib.ext import xtest
+d=display.Display(sys.argv[4]); root=d.screen().root
+def walk(w):
+    for c in w.query_tree().children:
+        yield c; yield from walk(c)
+for w in walk(root):
+    try: n=w.get_wm_name()
+    except Exception: n=None
+    if n=='Wallpaper UI': w.unmap()
+for w in root.query_tree().children:
+    g=w.get_geometry()
+    if w.get_attributes().map_state==2 and min(g.width,g.height)<=20 and (g.width,g.height)!=(1,1): w.unmap()
+d.sync(); time.sleep(2)
+with mss.MSS(display=sys.argv[4]) as m:
+    for i,p in enumerate(sys.argv[2].split()):
+        x,y=map(int,p.split(','))
+        # small moves first so WE sees motion events, then the target
+        for k in range(10):
+            xtest.fake_input(d, 6, x=x+(10-k), y=y); d.sync(); time.sleep(0.05)
+        xtest.fake_input(d, 6, x=x, y=y); d.sync()
+        time.sleep(float(sys.argv[3]))
+        s=m.grab({'left':0,'top':0,'width':1920,'height':1080})
+        Image.frombytes('RGB',s.size,s.rgb).save('%s/f%d_%d_%d.png'%(sys.argv[1],i,x,y))
+PY
+/usr/lib/wine/wineserver -k 2>/dev/null

+ 19 - 0
tools/wecompare/wine/we_display.sh

@@ -0,0 +1,19 @@
+#!/bin/bash
+# starts the isolated GPU display :98 (headless weston + Xwayland, private XDG_RUNTIME_DIR) if it isn't running
+# :0 is the real desktop, weston's own xwayland module would try it first, so Xwayland is started here instead
+S=${WE_LIVE_DIR:-$HOME/.local/share/we_live}
+R=$S/run
+mkdir -p $R && chmod 700 $R
+[ -f $S/weston.ini ] || printf '[core]\nxwayland=false\n[output]\nname=headless\nmode=1920x1080\n' > $S/weston.ini
+if ! pgrep -x weston >/dev/null; then
+    rm -f $R/we-wl*
+    env -u WAYLAND_DISPLAY -u DISPLAY XDG_RUNTIME_DIR=$R setsid nohup weston --backend=headless --renderer=gl \
+        --width=1920 --height=1080 --socket=we-wl --config=$S/weston.ini --log=$R/weston.log >/dev/null 2>&1 &
+    sleep 3
+fi
+if ! pgrep -f "Xwayland :98" >/dev/null; then
+    env -u DISPLAY XDG_RUNTIME_DIR=$R WAYLAND_DISPLAY=we-wl setsid nohup Xwayland :98 -geometry 1920x1080 -nolisten tcp \
+        > $R/xwayland.log 2>&1 &
+    sleep 3
+fi
+DISPLAY=:98 xdpyinfo >/dev/null 2>&1 && echo ":98 ready" || { echo ":98 failed, see $R"; exit 1; }

+ 46 - 0
tools/wecompare/wine/we_full.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+# usage: we_full.sh <scene dir or scene.json/scene.pkg> <out dir> [count] [interval seconds]
+# runs on the GPU display :98 (DXVK); WE_DISPLAY=:97 WE_WINED3D=1 is the old Xvfb + wined3d (llvmpipe) path
+# full window captures (1920x1058 at +4+30 on :97) of real WE 2.8.42 under Wine, see README.md
+S=${WE_LIVE_DIR:-$HOME/.local/share/we_live}
+D=${WE_DISPLAY:-:98}
+[ "$D" = ":98" ] && { bash "$(dirname "$0")/we_display.sh" >/dev/null || exit 1; }
+export WINEPREFIX=$S/wineprefix DISPLAY=$D WINEDEBUG=-all DXVK_LOG_PATH=$S
+# Wine must only see the isolated :98, not the desktop session (wayland-0, D-Bus) in the shared runtime dir
+unset WAYLAND_DISPLAY WAYLAND_SOCKET DBUS_SESSION_BUS_ADDRESS; export XDG_RUNTIME_DIR=$S/run
+# keep Wine off the desktop's audio server (a shared /run/user/<uid>/pulse would play the wallpaper's sounds on real speakers and feed real system audio to the visualizers)
+export PULSE_SERVER=unix:$S/run/no-pulse PIPEWIRE_REMOTE=$S/run/no-pipewire ALSA_CONFIG_PATH=/dev/null
+[ -n "$WE_WINED3D" ] && export WINEDLLOVERRIDES="d3d11,d3d10core,dxgi=b"
+[ "$D" != ":97" ] || pgrep -f "Xvfb :97" >/dev/null || { Xvfb :97 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; }
+/usr/lib/wine/wineserver -k 2>/dev/null; timeout 4 tail -f /dev/null
+cd $S/we28
+F=$1; [ -d "$F" ] && F=$F/scene.json
+WIN=$(echo "Z:$F" | tr '/' '\\')
+nohup /usr/lib/wine/wine64 wallpaper64.exe -control openWallpaper -file "$WIN" -monitor 0 -playInWindow -silent > $S/we_run_last.log 2>&1 &
+timeout 25 tail -f /dev/null
+mkdir -p "$2"
+python3 - "$2" "${3:-10}" "${4:-1}" "$D" <<'PY'
+import sys, time, mss
+from PIL import Image
+from Xlib import display
+d=display.Display(sys.argv[4]); root=d.screen().root
+def walk(w):
+    for c in w.query_tree().children:
+        yield c; yield from walk(c)
+for w in walk(root):
+    try: n=w.get_wm_name()
+    except Exception: n=None
+    if n=='Wallpaper UI': w.unmap()
+# the hidden UI leaves its border/shadow strips (8px override-redirect windows) and a small bar on top
+for w in root.query_tree().children:
+    g=w.get_geometry()
+    if w.get_attributes().map_state==2 and min(g.width,g.height)<=20 and (g.width,g.height)!=(1,1): w.unmap()
+d.sync(); time.sleep(2)
+t0=time.time()
+with mss.MSS(display=sys.argv[4]) as m:
+    for i in range(int(sys.argv[2])):
+        s=m.grab({'left':0,'top':0,'width':1920,'height':1080})
+        Image.frombytes('RGB',s.size,s.rgb).save('%s/f%03d_%.1f.png'%(sys.argv[1],i,time.time()-t0))
+        time.sleep(float(sys.argv[3]))
+PY
+/usr/lib/wine/wineserver -k 2>/dev/null