Jelajahi Sumber

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 3 jam lalu
induk
melakukan
6795f187d9
64 mengubah file dengan 3903 tambahan dan 534 penghapusan
  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. TEMPAT SAMPAH
      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. TEMPAT SAMPAH
      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;
 };
 }

TEMPAT SAMPAH
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]`.

TEMPAT SAMPAH
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