UwU 1 тиждень тому
батько
коміт
2b879b8a2c
72 змінених файлів з 5763 додано та 448 видалено
  1. 1 1
      .gitmodules
  2. 13 1
      CMakeLists.txt
  3. 130 0
      docs/hotswap-extended-state.md
  4. 74 0
      docs/investigations/puppet-and-renderer-notes.md
  5. 1 1
      src/External/argparse
  6. 78 0
      src/WallpaperEngine/Application/ApplicationContext.cpp
  7. 23 0
      src/WallpaperEngine/Application/ApplicationContext.h
  8. 235 65
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  9. 20 7
      src/WallpaperEngine/Application/WallpaperApplication.h
  10. 5 0
      src/WallpaperEngine/Assets/AssetLocator.cpp
  11. 1 0
      src/WallpaperEngine/Assets/AssetLocator.h
  12. 13 1
      src/WallpaperEngine/Data/Builders/ColorBuilder.cpp
  13. 13 1
      src/WallpaperEngine/Data/Model/DynamicValue.cpp
  14. 4 0
      src/WallpaperEngine/Data/Model/DynamicValue.h
  15. 9 1
      src/WallpaperEngine/Data/Model/Object.h
  16. 48 0
      src/WallpaperEngine/Data/Model/PropertyAnimation.h
  17. 135 1
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
  18. 6 0
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  19. 3 1
      src/WallpaperEngine/Data/Parsers/PropertyParser.cpp
  20. 84 3
      src/WallpaperEngine/FileSystem/Adapters/Directory.cpp
  21. 2 0
      src/WallpaperEngine/FileSystem/Adapters/Directory.h
  22. 40 10
      src/WallpaperEngine/FileSystem/Adapters/Package.cpp
  23. 2 0
      src/WallpaperEngine/FileSystem/Adapters/Package.h
  24. 26 0
      src/WallpaperEngine/FileSystem/Adapters/Types.h
  25. 26 1
      src/WallpaperEngine/FileSystem/Container.cpp
  26. 4 0
      src/WallpaperEngine/FileSystem/Container.h
  27. 129 0
      src/WallpaperEngine/Media/ThumbnailPalette.cpp
  28. 28 0
      src/WallpaperEngine/Media/ThumbnailPalette.h
  29. 2 0
      src/WallpaperEngine/Render/CTexture.cpp
  30. 14 0
      src/WallpaperEngine/Render/CWallpaper.cpp
  31. 3 0
      src/WallpaperEngine/Render/CWallpaper.h
  32. 293 34
      src/WallpaperEngine/Render/Objects/CImage.cpp
  33. 13 0
      src/WallpaperEngine/Render/Objects/CImage.h
  34. 3 3
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  35. 1 1
      src/WallpaperEngine/Render/Objects/CRenderable.cpp
  36. 13 2
      src/WallpaperEngine/Render/Objects/CSound.cpp
  37. 6 0
      src/WallpaperEngine/Render/Objects/CSound.h
  38. 396 117
      src/WallpaperEngine/Render/Objects/CText.cpp
  39. 10 3
      src/WallpaperEngine/Render/Objects/CText.h
  40. 151 10
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  41. 7 2
      src/WallpaperEngine/Render/RenderContext.cpp
  42. 4 1
      src/WallpaperEngine/Render/RenderContext.h
  43. 27 2
      src/WallpaperEngine/Render/Shaders/GLSLContext.cpp
  44. 249 4
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  45. 9 0
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  46. 49 17
      src/WallpaperEngine/Render/TextureCache.cpp
  47. 11 3
      src/WallpaperEngine/Render/TextureCache.h
  48. 50 0
      src/WallpaperEngine/Render/WallpaperState.cpp
  49. 9 2
      src/WallpaperEngine/Render/WallpaperState.h
  50. 153 29
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  51. 10 0
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  52. 1218 0
      src/WallpaperEngine/Render/Wallpapers/CSplat.cpp
  53. 168 0
      src/WallpaperEngine/Render/Wallpapers/CSplat.h
  54. 111 0
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  55. 24 9
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  56. 395 0
      src/WallpaperEngine/Scripting/AnimationSystem.cpp
  57. 113 0
      src/WallpaperEngine/Scripting/AnimationSystem.h
  58. 95 17
      src/WallpaperEngine/Scripting/EngineObject.cpp
  59. 21 34
      src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
  60. 15 29
      src/WallpaperEngine/Scripting/Modules/MathModule.cpp
  61. 47 0
      src/WallpaperEngine/Scripting/SceneObject.cpp
  62. 403 24
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  63. 36 2
      src/WallpaperEngine/Scripting/ScriptEngine.h
  64. 47 6
      src/WallpaperEngine/Scripting/ScriptableObject.cpp
  65. 11 1
      src/WallpaperEngine/Scripting/ScriptableObject.h
  66. 218 0
      src/WallpaperEngine/Splat/SogLoader.cpp
  67. 41 0
      src/WallpaperEngine/Splat/SogLoader.h
  68. 48 0
      src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
  69. 22 0
      src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp
  70. 65 0
      src/WallpaperEngine/Testing/Cases/ThumbnailPalette.cpp
  71. 1 1
      src/main.cpp
  72. 28 1
      tools/headless_render.sh

+ 1 - 1
.gitmodules

@@ -24,7 +24,7 @@
 	branch = master
 [submodule "src/External/argparse"]
 	path = src/External/argparse
-	url = https://github.com/p-ranav/argparse.git
+	url = https://github.com/WallpaperEngineLover/argparse.git
 	branch = master
 [submodule "src/External/Catch2"]
 	path = src/External/Catch2

+ 13 - 1
CMakeLists.txt

@@ -376,6 +376,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Media/MediaSource.cpp
     src/WallpaperEngine/Media/DBusMediaSource.h
     src/WallpaperEngine/Media/DBusMediaSource.cpp
+    src/WallpaperEngine/Media/ThumbnailPalette.h
+    src/WallpaperEngine/Media/ThumbnailPalette.cpp
 
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.h
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.cpp
@@ -438,6 +440,11 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
     src/WallpaperEngine/Render/Wallpapers/CWeb.h
     src/WallpaperEngine/Render/Wallpapers/CWeb.cpp
+    src/WallpaperEngine/Render/Wallpapers/CSplat.h
+    src/WallpaperEngine/Render/Wallpapers/CSplat.cpp
+
+    src/WallpaperEngine/Splat/SogLoader.h
+    src/WallpaperEngine/Splat/SogLoader.cpp
     src/WallpaperEngine/Render/Camera.h
     src/WallpaperEngine/Render/Camera.cpp
     src/WallpaperEngine/Render/CObject.h
@@ -494,6 +501,7 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Model/UserSetting.h
     src/WallpaperEngine/Data/Model/DynamicValue.h
     src/WallpaperEngine/Data/Model/DynamicValue.cpp
+    src/WallpaperEngine/Data/Model/PropertyAnimation.h
     src/WallpaperEngine/Data/Model/Property.h
     src/WallpaperEngine/Data/Utils/TypeCaster.h
     src/WallpaperEngine/Data/Utils/BinaryReader.cpp
@@ -566,6 +574,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Scripting/ScriptEngine.cpp
     src/WallpaperEngine/Scripting/ScriptableObject.h
     src/WallpaperEngine/Scripting/ScriptableObject.cpp
+    src/WallpaperEngine/Scripting/AnimationSystem.h
+    src/WallpaperEngine/Scripting/AnimationSystem.cpp
 
     src/WallpaperEngine/Maths.h
     src/WallpaperEngine/Maths.cpp)
@@ -589,7 +599,9 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/CornerColor.cpp
         src/WallpaperEngine/Testing/Cases/JsonVectorParsing.cpp
         src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp
-        src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp)
+        src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp
+        src/WallpaperEngine/Testing/Cases/ThumbnailPalette.cpp
+        src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp)
 endif()
 
 add_library(

+ 130 - 0
docs/hotswap-extended-state.md

@@ -0,0 +1,130 @@
+# Task: extend hotswap to cover layers, volume, and other live settings
+
+## Context
+
+`--list-objects` / `--disable-object` / `--enable-object` were recently added
+(see `ApplicationContext.cpp`), and the we_manager Electron app now has a UI
+for toggling layers and adjusting per-wallpaper volume live from a sidebar.
+
+Because those settings are only read from argv at process startup
+(`ApplicationContext::loadSettingsFromArgv`), the app currently applies any
+change by killing the running process and spawning a brand new one with the
+updated flags. That works, but it's the wrong tool for the job:
+
+- Full CEF/GL/DBus teardown and reinit on every toggle, visible as a flash.
+- A real bug we hit and fixed on the app side: `stopLwe()` sends SIGTERM and
+  moves on without waiting for the old process to actually exit. If it's
+  still shutting down when the new one starts, both processes are briefly
+  alive at once (we saw `DBus service org.linuxwallpaperengine.WaylandDetector
+  is already owned by another process` in the logs from exactly this). The
+  app now guards against the worst symptom (a corrupted `activeProcess`
+  reference that made the new launch hang forever - see
+  `we_manager/src/main/services/lwe.service.ts`, the `cleanup` closure in
+  `launchLweAsync`), but the underlying process-overlap window is still
+  there and still wasteful.
+- Every toggle costs ~2-3 seconds end to end for something that should be
+  near-instant.
+
+The hotswap mechanism (SIGUSR1 + control file) already exists for switching
+which background plays, without a process restart. This task is to extend
+it to carry more than just a path, so layers/volume/etc. can be updated the
+same lightweight way.
+
+## Current hotswap mechanism
+
+- `WallpaperApplication::checkHotswapRequest()` in
+  `src/WallpaperEngine/Application/WallpaperApplication.cpp` (~line 506).
+- Reads a single line (the new background path) from
+  `$XDG_RUNTIME_DIR/lwe-control` (or `/tmp/lwe-control`), triggered by
+  SIGUSR1 (`m_hotswapRequested` flag, set in `WallpaperApplication::signal`).
+- Fully reloads the project (`loadBackground`), re-runs
+  `setupPropertiesForProject`, and reconstructs a `CWallpaper` per screen via
+  `CWallpaper::fromWallpaper(...)`.
+- On the app side, this is driven by `hotReloadLwe()` in
+  `we_manager/src/main/services/lwe.service.ts`, which just writes the path
+  and sends the signal.
+
+## Why this should be less work than it sounds
+
+Both target settings already re-evaluate fresh on every reload, they're just
+not reachable from the control-file path yet:
+
+- **Layers**: `ApplicationContext::resolveObjectVisibility(id, name)`
+  (`ApplicationContext.cpp:261`) reads straight from
+  `settings.general.disabledObjects` / `enabledObjects`, and is called during
+  object construction (`CImage.cpp:865,929`, `CText.cpp:592`,
+  `CParticle.cpp:178`, `CScene.cpp:366`) - which already runs again on every
+  hotswap reload. If the control-file handler updates those two vectors on
+  `m_context.settings.general` *before* calling `loadBackground`, visibility
+  should just work without touching the object-construction code at all.
+
+- **Volume**: `GLPlayer::setVolume(double)`
+  (`VideoPlayback/MPV/GLPlayer.cpp:102`) already does a live
+  `mpv_set_property(..., "volume", ...)` on the existing mpv handle - no
+  reload needed at all for video wallpapers, it just needs a path from the
+  control file to this call. Scene/particle audio (`Render/Objects/CSound.h`)
+  wasn't checked in detail; confirm whether it has an equivalent live setter
+  or needs the same "re-apply on reload" treatment as layers.
+
+## Proposed design
+
+1. Replace the single-line control file with a small line-based or JSON
+   format so it can carry multiple fields without breaking existing readers,
+   e.g.:
+   ```
+   path=/path/to/wallpaper
+   disable-object=13
+   disable-object=110
+   enable-object=7
+   volume=42
+   ```
+   or a JSON object with the same fields. Either is fine; pick whichever is
+   less code given `checkHotswapRequest` is currently a simple
+   `std::getline`.
+
+2. In `checkHotswapRequest`, before calling `loadBackground`:
+   - If object fields are present, replace
+     `m_context.settings.general.disabledObjects` /
+     `enabledObjects` with the new lists.
+   - If a volume field is present and the current wallpaper is a video,
+     route it to `GLPlayer::setVolume` directly (no reload needed - this can
+     probably be handled as its own signal path, separate from a full
+     background reload, since it doesn't need `loadBackground` at all).
+
+3. Path stays optional in the payload: a volume-only or layers-only update
+   shouldn't require re-sending the current path and doing a full reload
+   when nothing about the *project* changed. Worth splitting into two
+   distinct requests internally: "reload background" (path/layers, since
+   layers currently only take effect through object construction) vs.
+   "live property push" (volume, and anything else with a direct setter).
+
+## Acceptance criteria
+
+- Toggling a layer on a running wallpaper no longer restarts the process
+  (no new PID, no DBus/CEF re-init log lines).
+- Changing volume on a running video wallpaper is audible within one mpv
+  property update, no visual interruption.
+- Existing plain path-only hotswap (playlist advance, "Play wallpaper" on a
+  different item) keeps working unchanged.
+- Rapid repeated toggles (stress case: 10 layer toggles in under 2 seconds)
+  don't crash, hang, or leak processes.
+
+## Follow-up work in we_manager (once this lands)
+
+- `src/main/services/lwe.service.ts`: extend `getControlFilePath`/
+  `hotReloadLwe` to write the richer payload, and add a variant that doesn't
+  require `isLweRunning()` to be false (currently only used as a fallback
+  inside `launchLweAsync`).
+- `src/renderer/src/components/common/DetailSidebar.tsx`: `toggleObject` and
+  `commitVolume` currently call `lwe.stop()` + `wallpaper.apply()` when
+  `isActive` is true - swap that for a new lighter IPC call (e.g.
+  `lwe.hotswapSettings(...)`) once the above exists, dropping the ~2-3s
+  relaunch wait and the "live" restart flash entirely.
+
+## Non-goals
+
+- fps: changing FPS live is a bigger change (render loop timing), not
+  covered here - keep it on the restart path unless someone wants to take
+  that on separately.
+- Web wallpapers: CEF browser reload semantics are different enough from
+  scene/video that they're out of scope for this task.

Різницю між файлами не показано, бо вона завелика
+ 74 - 0
docs/investigations/puppet-and-renderer-notes.md


+ 1 - 1
src/External/argparse

@@ -1 +1 @@
-Subproject commit d924b84eba1f0f0adf38b20b7b4829f6f65b6570
+Subproject commit 86d34c11d26a03c75d367e098b921f9857e64613

+ 78 - 0
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -276,6 +276,22 @@ std::optional<bool> ApplicationContext::resolveObjectVisibility (int id, const s
     return std::nullopt;
 }
 
+std::optional<bool> ApplicationContext::resolveEffectVisibility (int id, const std::string& name) const {
+    for (const auto& token : this->settings.general.disabledEffects) {
+	if (matchesObjectToken (token, id, name)) {
+	    return false;
+	}
+    }
+
+    for (const auto& token : this->settings.general.enabledEffects) {
+	if (matchesObjectToken (token, id, name)) {
+	    return true;
+	}
+    }
+
+    return std::nullopt;
+}
+
 std::optional<float> ApplicationContext::resolveAudioSensitivity (int id, const std::string& name) const {
     // "*" is a wildcard default, checked last so a specific id/name match always wins
     // regardless of the map's (alphabetically ordered) iteration order.
@@ -590,6 +606,43 @@ void ApplicationContext::loadSettingsFromArgv () {
 	})
 	.append ();
 
+    backgroundGroup.add_argument ("--offset")
+	.help (
+	    "Re-centers the visible crop window a cropping scaling mode (e.g. --scaling fill) or --zoom produces, "
+	    "as \"X,Y\" with each axis in [-1, 1] (0,0 is centered/default, e.g. -1,0 shows as much of the left "
+	    "edge as the crop allows, 0,1 shows as much of the top edge as the crop allows). Has no visible "
+	    "effect where nothing is being cropped. This applies to the previous --window, --screen-root, or "
+	    "--screen-span output, or the default background if no other background is specified"
+	)
+	// "-1,0" would otherwise look like another option to argparse
+	.allow_optional_like_value ()
+	.action ([this, &lastScreen] (const std::string& value) -> void {
+	    const auto comma = value.find (',');
+
+	    if (comma == std::string::npos) {
+		sLog.exception ("Offset must be in the format: X,Y");
+	    }
+
+	    glm::vec2 offset;
+
+	    try {
+		offset.x = std::stof (value.substr (0, comma));
+		offset.y = std::stof (value.substr (comma + 1));
+	    } catch (const std::exception&) {
+		sLog.exception ("Invalid offset value: ", value);
+	    }
+
+	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
+		this->settings.general.screenOffsets[lastScreen] = offset;
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().offset = offset;
+		}
+	    } else {
+		this->settings.render.window.offset = offset;
+	    }
+	})
+	.append ();
+
     backgroundGroup.add_argument ("--corner-color")
 	.help (
 	    "Color to show outside the wallpaper's bounds (Center/Fit letterboxing, zoomed-out scaling) when "
@@ -768,6 +821,11 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.general.disableParticles = true; });
 
+    configurationGroup.add_argument ("--disable-animations")
+	.help ("Freezes all scene animation (scripts, particles, effects and puppet meshes) at its current frame")
+	.flag ()
+	.action ([this] (const std::string& value) -> void { this->settings.render.freezeAnimations = true; });
+
     configurationGroup.add_argument ("--disable-mouse")
 	.help ("Disables mouse interaction with the backgrounds")
 	.flag ()
@@ -825,6 +883,26 @@ void ApplicationContext::loadSettingsFromArgv () {
 	)
 	.append ();
 
+    configurationGroup.add_argument ("--list-effects")
+	.help ("List all effects (bloom, blur, glow, etc) attached to a background's objects, with their id, editor "
+	       "name and category")
+	.flag ()
+	.store_into (this->settings.general.onlyListEffects);
+
+    configurationGroup.add_argument ("--disable-effect")
+	.help ("Disables an object's visual effect (bloom, blur, glow, etc), matched by effect id or editor name. "
+	       "Can be repeated")
+	.action ([this] (const std::string& value) -> void { this->settings.general.disabledEffects.push_back (value); }
+	)
+	.append ();
+
+    configurationGroup.add_argument ("--enable-effect")
+	.help ("Forces an effect to show even if the scene hides it by default, matched by effect id or editor name. "
+	       "Can be repeated")
+	.action ([this] (const std::string& value) -> void { this->settings.general.enabledEffects.push_back (value); }
+	)
+	.append ();
+
     configurationGroup.add_argument ("--list-audio-objects")
 	.help ("List objects/properties whose script reacts to music (via engine.registerAudioBuffers), with their "
 	       "current minvalue/maxvalue/frequency/smoothing")

+ 23 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -7,6 +7,7 @@
 #include <string>
 #include <vector>
 
+#include <glm/vec2.hpp>
 #include <glm/vec4.hpp>
 
 #include "ApplicationState.h"
@@ -33,6 +34,9 @@ public:
      */
     [[nodiscard]] std::optional<bool> resolveObjectVisibility (int id, const std::string& name) const;
 
+    /** true to force visible, false to force hidden, nullopt to leave the scene's own value, from --disable-effect/--enable-effect */
+    [[nodiscard]] std::optional<bool> resolveEffectVisibility (int id, const std::string& name) const;
+
     /**
      * Resolves the --audio-sensitivity multiplier for an object/layer, matching id or name, or
      * falling back to a "*" wildcard default if one was given and no more specific match exists.
@@ -92,6 +96,8 @@ public:
 	TextureFlags clamp = TextureFlags_ClampUVsBorder;
 	/** Manual zoom factor layered on top of the scaling mode, see --zoom */
 	float zoom = 1.0f;
+	/** Re-centers a cropping scaling mode/zoom's visible window, see --offset */
+	glm::vec2 offset = { 0.0f, 0.0f };
 	/** Color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 	glm::vec4 cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
     };
@@ -101,12 +107,17 @@ public:
 	    bool onlyListProperties;
 	    bool onlyListObjects;
 	    bool onlyListAudioObjects;
+	    bool onlyListEffects;
 	    bool dumpStructure;
 	    bool disableParticles;
 	    /** Objects/layers to force-hide, matched by id or name */
 	    std::vector<std::string> disabledObjects;
 	    /** Objects/layers to force-show, matched by id or name */
 	    std::vector<std::string> enabledObjects;
+	    /** Object effects (bloom, blur, glow, etc) to force-hide, matched by effect id or editor name */
+	    std::vector<std::string> disabledEffects;
+	    /** Object effects to force-show, matched by effect id or editor name */
+	    std::vector<std::string> enabledEffects;
 	    /** Audio-reactive pulse amplitude multiplier per object, matched by id or name; 0 = locked/no pulse */
 	    std::map<std::string, float> audioSensitivity;
 	    /** Sound object volume override (0-1), matched by id or name; see --sound-volume */
@@ -120,6 +131,8 @@ public:
 	    std::map<std::string, TextureFlags> screenClamps;
 	    /** Manual zoom factor for different screens, layered on top of the scaling mode */
 	    std::map<std::string, float> screenZooms;
+	    /** Re-centers a cropping scaling mode/zoom's visible window for different screens, see --offset */
+	    std::map<std::string, glm::vec2> screenOffsets;
 	    /** Corner color for different screens, shown outside the wallpaper's bounds when clamp is border */
 	    std::map<std::string, glm::vec4> screenCornerColors;
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
@@ -143,6 +156,8 @@ public:
 	    int maximumFPS;
 	    /** Global playback speed multiplier for animations, particles and effects, see --speed */
 	    float playbackSpeed;
+	    /** Freezes scene time entirely (scripts, particles, effects and puppet meshes all stop advancing), see --disable-animations */
+	    bool freezeAnimations;
 	    bool pauseOnFullscreen;
 	    /**
 	     * Wayland-only: if true, only consider fullscreen toplevels that are also activated.
@@ -173,6 +188,8 @@ public:
 		WallpaperEngine::Render::WallpaperState::TextureUVsScaling scalingMode;
 		/** Manual zoom factor layered on top of scalingMode, see --zoom */
 		float zoom;
+		/** Re-centers a cropping scaling mode/zoom's visible window, see --offset */
+		glm::vec2 offset;
 		/** Corner color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 		glm::vec4 cornerColor;
 	    } window;
@@ -212,9 +229,12 @@ public:
             .onlyListProperties = false,
             .onlyListObjects = false,
             .onlyListAudioObjects = false,
+            .onlyListEffects = false,
             .dumpStructure = false,
             .disabledObjects = {},
             .enabledObjects = {},
+            .disabledEffects = {},
+            .enabledEffects = {},
             .audioSensitivity = {},
             .soundVolume = {},
             .assets = "",
@@ -224,6 +244,7 @@ public:
             .screenScalings = {},
             .screenClamps = {},
             .screenZooms = {},
+            .screenOffsets = {},
             .screenCornerColors = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
@@ -237,6 +258,7 @@ public:
             .mode = NORMAL_WINDOW,
             .maximumFPS = 60,
             .playbackSpeed = 1.0f,
+            .freezeAnimations = false,
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
@@ -255,6 +277,7 @@ public:
                 .clamp = TextureFlags_ClampUVsBorder,
                 .scalingMode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs,
                 .zoom = 1.0f,
+                .offset = { 0.0f, 0.0f },
                 .cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f },
             },
             .wayland = {

+ 235 - 65
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -125,6 +125,7 @@ WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_con
     this->setupAudioSensitivity ();
     this->setupSoundVolume ();
     this->listObjects ();
+    this->listEffects ();
     this->listAudioObjects ();
     this->setupBrowser ();
     this->initializePlaylists ();
@@ -134,7 +135,8 @@ void WallpaperApplication::initializeSubsystems () {
     m_mediaSource = std::make_unique<WallpaperEngine::Media::DBusMediaSource> (std::chrono::milliseconds (2000));
 }
 
-AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string& bg) const {
+AssetLocatorUniquePtr
+WallpaperApplication::setupAssetLocator (const std::string& bg, const std::filesystem::path& overlay) const {
     auto container = std::make_unique<Container> ();
 
     const std::filesystem::path path = bg;
@@ -151,6 +153,10 @@ AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string
 	container->mount (path / "gifscene.pkg", "/");
     } catch (std::runtime_error&) { }
 
+    if (!overlay.empty ()) {
+	container->mount (overlay, "/");
+    }
+
     try {
 	container->mount (this->m_context.settings.general.assets, "/");
     } catch (std::runtime_error&) {
@@ -274,10 +280,66 @@ void WallpaperApplication::loadBackgrounds () {
     }
 }
 
-ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
-    auto container = this->setupAssetLocator (bg);
+WallpaperApplication::ProjectSource WallpaperApplication::openProjectSource (const std::string& path) const {
+    auto container = this->setupAssetLocator (path);
     auto json = WallpaperEngine::Data::JSON::JSON::parse (container->readString ("project.json"));
 
+    if (json.contains ("file") || !json.contains ("dependency")) {
+	return { std::move (container), std::move (json), std::nullopt };
+    }
+
+    const auto dependency = json.at ("dependency");
+    const auto dependencyId = dependency.is_string () ? dependency.get<std::string> () : dependency.dump ();
+
+    std::filesystem::path presetDir = path;
+
+    if (!presetDir.has_filename ()) {
+	presetDir = presetDir.parent_path ();
+    }
+
+    const auto baseDir = presetDir.parent_path () / dependencyId;
+
+    if (!std::filesystem::exists (baseDir / "project.json")) {
+	sLog.exception ("Preset ", path, " requires wallpaper ", dependencyId, " which is not installed");
+    }
+
+    auto baseContainer = this->setupAssetLocator (baseDir.string (), presetDir);
+    auto baseJson = WallpaperEngine::Data::JSON::JSON::parse (baseContainer->readString ("project.json"));
+
+    return { std::move (baseContainer), std::move (baseJson), json.optional ("preset") };
+}
+
+void WallpaperApplication::applyPreset (const Project& project, const WallpaperEngine::Data::JSON::JSON& preset) {
+    for (const auto& entry : preset.items ()) {
+	const auto property = project.properties.find (entry.key ());
+
+	// wec_* and friends are Wallpaper Engine's own per-preset settings, not wallpaper properties
+	if (property == project.properties.end () || entry.value ().is_null ()) {
+	    continue;
+	}
+
+	const auto& value = entry.value ();
+	std::string text;
+
+	if (value.is_string ()) {
+	    text = value.get<std::string> ();
+	} else if (value.is_boolean ()) {
+	    text = value.get<bool> () ? "true" : "false";
+	} else {
+	    text = value.dump ();
+	}
+
+	try {
+	    property->second->update (text, DynamicValue::UpdateSource::User);
+	} catch (const std::exception& e) {
+	    sLog.error ("Cannot apply preset value for ", entry.key (), ": ", e.what ());
+	}
+    }
+}
+
+ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
+    auto [container, json, preset] = this->openProjectSource (bg);
+
     // reset screenshot state so a background change (e.g. playlist advance) can screenshot again
     if (this->m_context.settings.screenshot.take) {
 	this->m_nextFrameScreenshot = this->m_context.settings.screenshot.delay;
@@ -289,7 +351,13 @@ ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
 	this->m_screenShotTaken = false;
     }
 
-    return WallpaperEngine::Data::Parsers::ProjectParser::parse (json, std::move (container));
+    auto project = WallpaperEngine::Data::Parsers::ProjectParser::parse (json, std::move (container));
+
+    if (preset.has_value ()) {
+	applyPreset (*project, *preset);
+    }
+
+    return project;
 }
 
 std::vector<std::size_t>
@@ -386,8 +454,8 @@ bool WallpaperApplication::makeAnyViewportCurrent () const {
 bool WallpaperApplication::preflightWallpaper (const std::string& path) {
     try {
 	// avoid mutating state, just ensure project.json parses
-	auto container = this->setupAssetLocator (path);
-	const auto json = WallpaperEngine::Data::JSON::JSON::parse (container->readString ("project.json"));
+	const auto source = this->openProjectSource (path);
+	const auto& json = source.json;
 	if (!json.contains ("type") || !json.contains ("file")) {
 	    sLog.error ("Preflight failed for ", path, ": missing required fields");
 	    return false;
@@ -473,32 +541,41 @@ void WallpaperApplication::advancePlaylist (
 
 	this->setupPropertiesForProject (*project);
 
-	// same reason as checkHotswapRequest() - keep the outgoing project alive past setWallpaper(),
-	// and past this function returning: if fromWallpaper() below throws (e.g. the new scene
-	// references a missing asset), the old CWallpaper for this screen is still installed and
-	// still rendering off this project's data, so a try-block-local variable freed in the catch
-	// below would leave it dangling
-	this->m_retiredProjects.push_back (std::move (this->m_backgrounds[screen]));
+	// the outgoing project must outlive setWallpaper(), the old wallpaper keeps a reference into it
+	auto outgoing = std::move (this->m_backgrounds[screen]);
 	this->m_backgrounds[screen] = std::move (project);
 
-	const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
-	const auto clampIt = this->m_context.settings.general.screenClamps.find (screen);
-	const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
-	    ? scalingIt->second
-	    : this->m_context.settings.render.window.scalingMode;
-	const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
-	    ? clampIt->second
-	    : this->m_context.settings.render.window.clamp;
+	try {
+	    const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
+	    const auto clampIt = this->m_context.settings.general.screenClamps.find (screen);
+	    const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
+		? scalingIt->second
+		: this->m_context.settings.render.window.scalingMode;
+	    const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
+		? clampIt->second
+		: this->m_context.settings.render.window.clamp;
 
-	if (this->m_renderContext) {
-	    auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-		*this->m_backgrounds[screen]->wallpaper, *this->m_renderContext, *this->m_audioContext, nextPath,
-		scaling, clamp, this->resolveScreenRenderSize (screen)
-	    );
-	    wallpaper->setZoom (this->resolveScreenZoom (screen));
-	    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
-	    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
-	    this->applyAudioPolicy ();
+	    if (this->m_renderContext) {
+		auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+		    *this->m_backgrounds[screen]->wallpaper, *this->m_renderContext, *this->m_audioContext, nextPath,
+		    scaling, clamp, this->resolveScreenRenderSize (screen)
+		);
+		wallpaper->setZoom (this->resolveScreenZoom (screen));
+		const auto offset = this->resolveScreenOffset (screen);
+		wallpaper->setOffset (offset.x, offset.y);
+		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
+		this->applyAudioPolicy ();
+	    }
+	} catch (...) {
+	    project = std::move (this->m_backgrounds[screen]);
+	    this->m_backgrounds[screen] = std::move (outgoing);
+
+	    if (this->m_renderContext) {
+		this->m_renderContext->pruneTextures ();
+	    }
+
+	    throw;
 	}
 
 	this->m_context.settings.general.screenBackgrounds[screen] = nextPath;
@@ -562,6 +639,8 @@ struct HotswapRequest {
     std::optional<std::string> scaling;
     /** floating-point zoom factor layered on top of scaling, e.g. "1.5" */
     std::optional<std::string> zoom;
+    /** "X,Y" offset re-center, each axis in [-1, 1], e.g. "0.5,-1" */
+    std::optional<std::string> offset;
     /** "on"/"off"/"toggle" (also "1"/"0"/"true"/"false" for on/off) */
     std::optional<std::string> disableParallax;
     /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
@@ -598,9 +677,9 @@ std::string trimHotswapToken (const std::string& value) {
 /**
  * Parses the control file. Supports the original bare-path-on-one-line format for backwards
  * compatibility, plus key=value lines (path/layers/disable-object/enable-object/volume/xray/scaling/zoom/
- * disable-parallax/corner-color/speed/audio-screen/ambient-volume/property) so a single request can carry
- * more than just the background path. "property=name=value" (repeatable) carries --set-property-equivalent
- * overrides.
+ * offset/disable-parallax/corner-color/speed/audio-screen/ambient-volume/property) so a single request can
+ * carry more than just the background path. "property=name=value" (repeatable) carries
+ * --set-property-equivalent overrides.
  */
 HotswapRequest parseHotswapRequest (std::istream& file) {
     HotswapRequest request;
@@ -649,6 +728,8 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    request.scaling = value;
 	} else if (key == "zoom") {
 	    request.zoom = value;
+	} else if (key == "offset") {
+	    request.offset = value;
 	} else if (key == "disable-parallax") {
 	    request.disableParallax = value;
 	} else if (key == "corner-color") {
@@ -714,7 +795,7 @@ void WallpaperApplication::checkHotswapRequest () {
 
     if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
-	&& !request.disableParallax.has_value () && !request.cornerColor.has_value ()
+	&& !request.offset.has_value () && !request.disableParallax.has_value () && !request.cornerColor.has_value ()
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
 	&& !request.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
@@ -737,6 +818,10 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applyZoomHotswap (*request.zoom);
     }
 
+    if (request.offset.has_value ()) {
+	this->applyOffsetHotswap (*request.offset);
+    }
+
     if (request.disableParallax.has_value ()) {
 	this->applyParallaxHotswap (*request.disableParallax);
     }
@@ -806,20 +891,7 @@ void WallpaperApplication::checkHotswapRequest () {
 	sLog.out ("Hotswapping wallpaper layers");
     }
 
-    // CWallpaper holds a raw reference into its Project (m_wallpaperData). Each loop iteration below
-    // used to keep its own outgoing project alive only via a try-block-local variable, on the assumption
-    // that it only needed to outlive setWallpaper() a few lines later - but if anything after the move
-    // on the next line throws (e.g. CWallpaper::fromWallpaper() failing to load an asset the new scene
-    // references, like a missing "models/bar.json"), the catch block below is reached with setWallpaper()
-    // never having run: the OLD CWallpaper for that screen is still installed and still rendering every
-    // frame off data that local variable is about to free at scope exit. The very next thing that touches
-    // every installed wallpaper is applyAudioPolicy() below, which segfaulted inside
-    // CSound::applyEffectiveVolume() reading a dangling Sound reference - confirmed by a real crash dump
-    // whose reported filesystem error (a missing model referenced mid-hotswap) lines up exactly with this
-    // window. Collecting them here instead, alive until this whole function returns (well past
-    // applyAudioPolicy()), fixes that regardless of which screen's swap failed or why.
-    std::vector<ProjectUniquePtr> outgoingProjects;
-
+    // the outgoing project must outlive setWallpaper(), the old wallpaper keeps a reference into it
     for (auto& [screen, background] : this->m_backgrounds) {
 	const std::string targetPath = request.path.value_or (this->resolveScreenBackgroundPath (screen));
 
@@ -830,26 +902,39 @@ void WallpaperApplication::checkHotswapRequest () {
 	    this->setupAudioSensitivityForProject (*project);
 	    this->setupSoundVolumeForProject (*project);
 
-	    outgoingProjects.push_back (std::move (background));
+	    auto outgoing = std::move (background);
 	    background = std::move (project);
 
-	    const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
-	    const auto clampIt = this->m_context.settings.general.screenClamps.find (screen);
-	    const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
-		? scalingIt->second
-		: this->m_context.settings.render.window.scalingMode;
-	    const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
-		? clampIt->second
-		: this->m_context.settings.render.window.clamp;
+	    try {
+		const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
+		const auto clampIt = this->m_context.settings.general.screenClamps.find (screen);
+		const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
+		    ? scalingIt->second
+		    : this->m_context.settings.render.window.scalingMode;
+		const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
+		    ? clampIt->second
+		    : this->m_context.settings.render.window.clamp;
+
+		if (this->m_renderContext) {
+		    auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+			*background->wallpaper, *this->m_renderContext, *this->m_audioContext, targetPath, scaling, clamp,
+			this->resolveScreenRenderSize (screen)
+		    );
+		    wallpaper->setZoom (this->resolveScreenZoom (screen));
+		    const auto offset = this->resolveScreenOffset (screen);
+		    wallpaper->setOffset (offset.x, offset.y);
+		    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
+		}
+	    } catch (...) {
+		project = std::move (background);
+		background = std::move (outgoing);
 
-	    if (this->m_renderContext) {
-		auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-		    *background->wallpaper, *this->m_renderContext, *this->m_audioContext, targetPath, scaling, clamp,
-		    this->resolveScreenRenderSize (screen)
-		);
-		wallpaper->setZoom (this->resolveScreenZoom (screen));
-		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
-		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
+		if (this->m_renderContext) {
+		    this->m_renderContext->pruneTextures ();
+		}
+
+		throw;
 	    }
 
 	    if (request.path.has_value ()) {
@@ -903,6 +988,14 @@ float WallpaperApplication::resolveScreenZoom (const std::string& screen) const
 								      : this->m_context.settings.render.window.zoom;
 }
 
+glm::vec2 WallpaperApplication::resolveScreenOffset (const std::string& screen) const {
+    const auto it = this->m_context.settings.general.screenOffsets.find (screen);
+
+    return it != this->m_context.settings.general.screenOffsets.end ()
+	? it->second
+	: this->m_context.settings.render.window.offset;
+}
+
 glm::vec4 WallpaperApplication::resolveScreenCornerColor (const std::string& screen) const {
     const auto it = this->m_context.settings.general.screenCornerColors.find (screen);
 
@@ -1018,6 +1111,43 @@ void WallpaperApplication::applyZoomHotswap (const std::string& value) {
     sLog.out ("Hotswap: applied zoom ", zoom, " live");
 }
 
+void WallpaperApplication::applyOffsetHotswap (const std::string& value) {
+    const auto comma = value.find (',');
+
+    if (comma == std::string::npos) {
+	sLog.error ("Hotswap: ignoring malformed offset value (expected X,Y): ", value);
+	return;
+    }
+
+    glm::vec2 offset;
+
+    try {
+	offset.x = std::stof (value.substr (0, comma));
+	offset.y = std::stof (value.substr (comma + 1));
+    } catch (const std::exception&) {
+	sLog.error ("Hotswap: ignoring invalid offset value: ", value);
+	return;
+    }
+
+    this->m_context.settings.render.window.offset = offset;
+
+    for (auto& [screen, screenOffset] : this->m_context.settings.general.screenOffsets) {
+	screenOffset = offset;
+    }
+
+    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	spanGroup.offset = offset;
+    }
+
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    wallpaper->setOffset (offset.x, offset.y);
+	}
+    }
+
+    sLog.out ("Hotswap: applied offset ", offset.x, ",", offset.y, " live");
+}
+
 void WallpaperApplication::applyParallaxHotswap (const std::string& value) {
     bool newState;
 
@@ -1201,6 +1331,41 @@ void WallpaperApplication::listObjects () const {
     }
 }
 
+void WallpaperApplication::listEffectsForProject (const std::string& background, const Project& project) const {
+    if (!project.wallpaper->is<Scene> ()) {
+	return;
+    }
+
+    const auto scene = project.wallpaper->as<Scene> ();
+
+    sLog.out ("Effects for ", background, ":");
+
+    for (const auto& object : scene->objects) {
+	if (!object->is<Image> ()) {
+	    continue;
+	}
+
+	const auto* image = object->as<Image> ();
+
+	for (const auto& effect : image->effects) {
+	    sLog.out (
+		"  ", effect->id, " - ", effect->name, " (object=", object->id, " objectName=\"", object->name,
+		"\" group=\"", effect->effect->group, "\")"
+	    );
+	}
+    }
+}
+
+void WallpaperApplication::listEffects () const {
+    if (!this->m_context.settings.general.onlyListEffects) {
+	return;
+    }
+
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->listEffectsForProject (background, *info);
+    }
+}
+
 namespace {
 struct AudioReactiveProperty {
     std::string name;
@@ -1726,6 +1891,8 @@ void WallpaperApplication::prepareOutputs () {
 	    scaling, clamp, this->resolveScreenRenderSize (background)
 	);
 	wallpaper->setZoom (this->resolveScreenZoom (background));
+	const auto offset = this->resolveScreenOffset (background);
+	wallpaper->setOffset (offset.x, offset.y);
 	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
 	m_renderContext->setWallpaper (background, std::move (wallpaper));
     }
@@ -1786,6 +1953,7 @@ void WallpaperApplication::prepareOutputs () {
 	    spanGroup.scaling, spanGroup.clamp, glm::ivec2 { maxX - minX, maxY - minY }
 	);
 	sharedWallpaper->setZoom (spanGroup.zoom);
+	sharedWallpaper->setOffset (spanGroup.offset.x, spanGroup.offset.y);
 	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
 
 	// Convert to shared_ptr so it can be registered for multiple viewports
@@ -1886,7 +2054,9 @@ void WallpaperApplication::render () {
 	rawTimeLast = rawTimeNow;
 
 	g_TimeLast = g_Time;
-	g_Time += rawDelta * this->m_context.settings.render.playbackSpeed;
+	if (!this->m_context.settings.render.freezeAnimations) {
+	    g_Time += rawDelta * this->m_context.settings.render.playbackSpeed;
+	}
 	g_RealTime = rawTimeNow;
 	m_audioDriver->update ();
 	m_mediaSource->update ();

+ 20 - 7
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -18,6 +18,7 @@
 #include "WallpaperEngine/Input/InputContext.h"
 #include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
 
+#include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Media/MediaSource.h"
 
@@ -56,7 +57,17 @@ public:
     [[nodiscard]] GLuint getDestinationFramebuffer () const;
 
 private:
-    AssetLocatorUniquePtr setupAssetLocator (const std::string& bg) const;
+    /** A project.json ready to be parsed. For presets, json is the base wallpaper's and preset holds the overrides */
+    struct ProjectSource {
+	AssetLocatorUniquePtr container;
+	WallpaperEngine::Data::JSON::JSON json;
+	std::optional<WallpaperEngine::Data::JSON::JSON> preset;
+    };
+
+    /** overlay is an extra folder mounted after bg, used for preset-owned files (e.g. splat data) */
+    AssetLocatorUniquePtr setupAssetLocator (const std::string& bg, const std::filesystem::path& overlay = {}) const;
+    ProjectSource openProjectSource (const std::string& path) const;
+    static void applyPreset (const Project& project, const WallpaperEngine::Data::JSON::JSON& preset);
     void initializeSubsystems ();
     void loadBackgrounds ();
     [[nodiscard]] ProjectUniquePtr loadBackground (const std::string& bg);
@@ -67,6 +78,10 @@ private:
     void listObjects () const;
     void listObjectsForProject (const std::string& background, const Project& project) const;
 
+    /** Prints per-object effects (bloom, blur, glow, etc) for every loaded background, triggered by --list-effects */
+    void listEffects () const;
+    void listEffectsForProject (const std::string& background, const Project& project) const;
+
     /** Applies --audio-sensitivity overrides for every loaded background */
     void setupAudioSensitivity ();
     void setupAudioSensitivityForProject (const Project& project) const;
@@ -131,6 +146,9 @@ private:
     /** Pushes a manual zoom factor (e.g. "1.5") live to every currently rendered wallpaper */
     void applyZoomHotswap (const std::string& value);
 
+    /** Pushes an offset re-center (e.g. "0.5,-1") live to every currently rendered wallpaper */
+    void applyOffsetHotswap (const std::string& value);
+
     /**
      * Pushes a force-disable-parallax toggle live. This is a straight passthrough to
      * settings.mouse.disableparallax, which every parallax-capable object (CImage/CText/CParticle) and
@@ -183,6 +201,7 @@ private:
      */
     [[nodiscard]] std::string resolveScreenBackgroundPath (const std::string& screen) const;
     [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
+    [[nodiscard]] glm::vec2 resolveScreenOffset (const std::string& screen) const;
     [[nodiscard]] glm::vec4 resolveScreenCornerColor (const std::string& screen) const;
     // The resolution a Web wallpaper on this screen will actually be rendered at - falls back to
     // the combined bounding box of every active screen if this one isn't registered yet.
@@ -191,12 +210,6 @@ private:
     ApplicationContext& m_context;
     std::map<std::string, ProjectUniquePtr> m_backgrounds {};
     std::map<std::string, ActivePlaylist> m_activePlaylists {};
-    // Projects displaced by a wallpaper swap that failed partway through (see advancePlaylist and
-    // checkHotswapRequest) - kept alive here rather than freed at the point of failure, since the
-    // still-installed old CWallpaper holds a raw reference into them. Deliberately never pruned; these
-    // are rare (asset-loading failures mid-swap), not a hot path, and an app-lifetime retention is a much
-    // smaller cost than the use-after-free it replaces.
-    std::vector<ProjectUniquePtr> m_retiredProjects {};
 
     std::unique_ptr<WallpaperEngine::Audio::Drivers::Detectors::AudioPlayingDetector> m_audioDetector = nullptr;
     std::unique_ptr<WallpaperEngine::Audio::AudioContext> m_audioContext = nullptr;

+ 5 - 0
src/WallpaperEngine/Assets/AssetLocator.cpp

@@ -92,4 +92,9 @@ std::filesystem::path AssetLocator::physicalPath (const std::filesystem::path& p
     } catch (std::filesystem::filesystem_error& base) {
 	throw AssetLoadException (base);
     }
+}
+
+std::optional<std::filesystem::path>
+AssetLocator::resolveWorkshopDependencyAlias (const std::filesystem::path& path) const {
+    return this->m_filesystem->resolveWorkshopDependencyAlias (path);
 }

+ 1 - 0
src/WallpaperEngine/Assets/AssetLocator.h

@@ -16,6 +16,7 @@ public:
     std::string readString (const std::filesystem::path& filename) const;
     ReadStreamSharedPtr read (const std::filesystem::path& path) const;
     std::filesystem::path physicalPath (const std::filesystem::path& path) const;
+    std::optional<std::filesystem::path> resolveWorkshopDependencyAlias (const std::filesystem::path& path) const;
 
 private:
     std::string shader (const std::filesystem::path& filename) const;

+ 13 - 1
src/WallpaperEngine/Data/Builders/ColorBuilder.cpp

@@ -49,7 +49,19 @@ WallpaperEngine::Data::Builders::ColorBuilder::parse (const std::string& value,
 	throw std::invalid_argument ("Invalid color value");
     }
 
-    if (copy.find ('.') == std::string::npos) {
+    // WE trims trailing zeros when it saves floats, so "1 1 1" is white and "0 0 0" black, not 1/255
+    const auto looksLikeTrimmedFloats = [&copy] () {
+	std::istringstream stream (copy);
+	int component = 0;
+	while (stream >> component) {
+	    if (component < 0 || component > 1) {
+		return false;
+	    }
+	}
+	return true;
+    };
+
+    if (copy.find ('.') == std::string::npos && !looksLikeTrimmedFloats ()) {
 	const auto final = vectorSize == 3 ? glm::ivec4 (VectorBuilder::parse<glm::ivec3> (copy), alpha * 255)
 					   : VectorBuilder::parse<glm::ivec4> (copy);
 

+ 13 - 1
src/WallpaperEngine/Data/Model/DynamicValue.cpp

@@ -201,6 +201,12 @@ void DynamicValue::update (const Model::Color& newValue, UpdateSource source) {
 }
 
 void DynamicValue::update (const DynamicValue& other, UpdateSource source) {
+    // a slider bound to a vector property sets every component, but the property stays a vector
+    const bool isVector = this->m_type == UnderlyingType::Vec2 || this->m_type == UnderlyingType::Vec3
+	|| this->m_type == UnderlyingType::Vec4;
+    const bool isScalar = other.getType () == UnderlyingType::Float || other.getType () == UnderlyingType::Int;
+    const auto keptType = this->m_type;
+
     this->m_vec2 = other.getVec2 ();
     this->m_vec3 = other.getVec3 ();
     this->m_vec4 = other.getVec4 ();
@@ -208,7 +214,7 @@ void DynamicValue::update (const DynamicValue& other, UpdateSource source) {
     this->m_int = other.getInt ();
     this->m_bool = other.getBool ();
     this->m_string = other.getString ();
-    this->m_type = other.getType ();
+    this->m_type = isVector && isScalar ? keptType : other.getType ();
 
     if (this->m_condition.has_value () && other.getType () == UnderlyingType::String) {
 	const bool boolValue = this->m_condition.value ().condition == other.getString ();
@@ -289,6 +295,12 @@ void DynamicValue::clearScriptSource () { this->m_scriptSource = std::nullopt; }
 
 const std::optional<std::string>& DynamicValue::getScriptSource () const { return this->m_scriptSource; }
 
+void DynamicValue::setAnimation (std::shared_ptr<const PropertyAnimation> animation) {
+    this->m_animation = std::move (animation);
+}
+
+const std::shared_ptr<const PropertyAnimation>& DynamicValue::getAnimation () const { return this->m_animation; }
+
 std::map<std::string, UserSettingUniquePtr>& DynamicValue::getProperties () { return this->m_properties; }
 
 void DynamicValue::setProperties (std::map<std::string, UserSettingUniquePtr> properties) {

+ 4 - 0
src/WallpaperEngine/Data/Model/DynamicValue.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "Color.h"
+#include "PropertyAnimation.h"
 #include "Types.h"
 
 #include <functional>
@@ -91,6 +92,8 @@ public:
     void clearScriptSource ();
     [[nodiscard]] const std::optional<std::string>& getScriptSource () const;
     [[nodiscard]] std::map<std::string, UserSettingUniquePtr>& getProperties ();
+    void setAnimation (std::shared_ptr<const PropertyAnimation> animation);
+    [[nodiscard]] const std::shared_ptr<const PropertyAnimation>& getAnimation () const;
     void setProperties (std::map<std::string, UserSettingUniquePtr> properties);
 
 private:
@@ -100,6 +103,7 @@ private:
     std::list<std::function<void (const DynamicValue&, UpdateSource)>> m_listeners = {};
     std::vector<std::function<void ()>> m_connections = {};
     std::optional<std::string> m_scriptSource = std::nullopt;
+    std::shared_ptr<const PropertyAnimation> m_animation;
 
     glm::vec4 m_vec4 = {};
     glm::vec3 m_vec3 = {};

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

@@ -107,6 +107,8 @@ struct SoundData {
     /** Per-object volume (0-1), independent of the global volume - lets a wallpaper with several
      *  Sound objects (e.g. alternate music tracks) mute all but one via --set-property */
     UserSettingUniquePtr volume;
+    /** Sound stays muted until a script calls play() on its layer */
+    std::optional<bool> startsilent;
 };
 
 class Sound : public Object, public SoundData {
@@ -576,7 +578,13 @@ struct TextData {
     glm::vec2 padding;
     /** Applied after the glyphs are rendered */
     std::vector<ImageEffectUniquePtr> effects;
-    // TODO: parse limits too
+    /** Wrap lines wider than maxWidth (layout pixels, before the object's scale) */
+    UserSettingUniquePtr limitWidth;
+    UserSettingUniquePtr maxWidth;
+    /** Cut the text after maxRows lines, optionally marking the cut with an ellipsis */
+    UserSettingUniquePtr limitRows;
+    UserSettingUniquePtr maxRows;
+    UserSettingUniquePtr limitUseEllipsis;
 };
 
 class Text : public Object, public TextData {

+ 48 - 0
src/WallpaperEngine/Data/Model/PropertyAnimation.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <array>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace WallpaperEngine::Data::Model {
+struct AnimationKeyframe {
+    struct Handle {
+	bool enabled = false;
+	float x = 0.0f;
+	float y = 0.0f;
+    };
+
+    float frame = 0.0f;
+    float value = 0.0f;
+    Handle back;
+    Handle front;
+};
+
+struct AnimationEvent {
+    float frame = 0.0f;
+    std::string name;
+};
+
+/**
+ * Keyframe timeline stored under a property's "animation" key. One curve per vector component
+ * (c0..c3); a component with no keyframes is left alone. Properties linked through
+ * children/parent share the playback state of the root one.
+ */
+struct PropertyAnimation {
+    enum class Mode { Single, Loop, Mirror };
+
+    std::array<std::vector<AnimationKeyframe>, 4> curves;
+    std::string name;
+    float fps = 30.0f;
+    float length = 0.0f;
+    Mode mode = Mode::Single;
+    bool startPaused = false;
+    bool wrapLoop = false;
+    /** Curve values are offsets on top of the property's own value instead of absolute values */
+    bool relative = false;
+    std::vector<AnimationEvent> events;
+    std::vector<std::string> children;
+    std::optional<std::string> parent;
+};
+} // namespace WallpaperEngine::Data::Model

+ 135 - 1
src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp

@@ -1,10 +1,137 @@
 #include "DynamicValueParser.h"
 
+#include <cstdlib>
+#include <sstream>
+
 #include "UserSettingParser.h"
 #include "WallpaperEngine/Data/Model/DynamicValue.h"
 
 using namespace WallpaperEngine::Data::Parsers;
 
+namespace {
+// free-form text is space-separated too, so every token has to be a number to count as a vector
+bool isNumericVector (const std::string& str) {
+    std::istringstream stream (str);
+    std::string token;
+    int count = 0;
+
+    while (stream >> token) {
+	char* end = nullptr;
+	std::strtof (token.c_str (), &end);
+
+	if (end == token.c_str () || *end != '\0') {
+	    return false;
+	}
+
+	count++;
+    }
+
+    return count >= 2;
+}
+
+std::shared_ptr<const PropertyAnimation> parseAnimation (const json& data) {
+    auto animation = std::make_shared<PropertyAnimation> ();
+
+    const auto readHandle = [] (const json& source, const char* key) {
+	AnimationKeyframe::Handle handle;
+	const auto it = source.find (key);
+
+	if (it == source.end () || !it->is_object ()) {
+	    return handle;
+	}
+
+	if (const auto enabled = it->find ("enabled"); enabled != it->end () && enabled->is_boolean ()) {
+	    handle.enabled = enabled->get<bool> ();
+	}
+	if (const auto x = it->find ("x"); x != it->end () && x->is_number ()) {
+	    handle.x = x->get<float> ();
+	}
+	if (const auto y = it->find ("y"); y != it->end () && y->is_number ()) {
+	    handle.y = y->get<float> ();
+	}
+
+	return handle;
+    };
+
+    for (size_t component = 0; component < animation->curves.size (); component++) {
+	const auto curve = data.find ("c" + std::to_string (component));
+
+	if (curve == data.end () || !curve->is_array ()) {
+	    continue;
+	}
+
+	for (const auto& key : *curve) {
+	    if (!key.is_object () || !key.contains ("frame") || !key.contains ("value")) {
+		continue;
+	    }
+
+	    animation->curves[component].push_back (AnimationKeyframe {
+		.frame = key["frame"].get<float> (),
+		.value = key["value"].get<float> (),
+		.back = readHandle (key, "back"),
+		.front = readHandle (key, "front"),
+	    });
+	}
+    }
+
+    if (const auto relative = data.find ("relative"); relative != data.end () && relative->is_boolean ()) {
+	animation->relative = relative->get<bool> ();
+    }
+
+    const auto options = data.find ("options");
+
+    if (options == data.end () || !options->is_object ()) {
+	return animation;
+    }
+
+    if (const auto it = options->find ("name"); it != options->end () && it->is_string ()) {
+	animation->name = it->get<std::string> ();
+    }
+    if (const auto it = options->find ("fps"); it != options->end () && it->is_number ()) {
+	animation->fps = it->get<float> ();
+    }
+    if (const auto it = options->find ("length"); it != options->end () && it->is_number ()) {
+	animation->length = it->get<float> ();
+    }
+    if (const auto it = options->find ("mode"); it != options->end () && it->is_string ()) {
+	const auto mode = it->get<std::string> ();
+	animation->mode = mode == "loop"     ? PropertyAnimation::Mode::Loop
+			  : mode == "mirror" ? PropertyAnimation::Mode::Mirror
+					     : PropertyAnimation::Mode::Single;
+    }
+    if (const auto it = options->find ("startpaused"); it != options->end () && it->is_boolean ()) {
+	animation->startPaused = it->get<bool> ();
+    }
+    if (const auto it = options->find ("wraploop"); it != options->end () && it->is_boolean ()) {
+	animation->wrapLoop = it->get<bool> ();
+    }
+    if (const auto it = options->find ("events"); it != options->end () && it->is_array ()) {
+	for (const auto& event : *it) {
+	    if (event.is_object () && event.contains ("frame") && event.contains ("name")) {
+		animation->events.push_back (AnimationEvent {
+		    .frame = event["frame"].get<float> (),
+		    .name = event["name"].get<std::string> (),
+		});
+	    }
+	}
+    }
+    if (const auto it = options->find ("children"); it != options->end () && it->is_array ()) {
+	for (const auto& child : *it) {
+	    if (child.is_object () && child.contains ("key") && child["key"].is_string ()) {
+		animation->children.push_back (child["key"].get<std::string> ());
+	    }
+	}
+    }
+    if (const auto it = options->find ("parent"); it != options->end () && it->is_object ()) {
+	if (it->contains ("key") && (*it)["key"].is_string ()) {
+	    animation->parent = (*it)["key"].get<std::string> ();
+	}
+    }
+
+    return animation;
+}
+}
+
 DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Properties& properties, bool expectColor) {
     auto value = std::make_unique<DynamicValue> ();
     auto valueIt = data;
@@ -27,7 +154,7 @@ DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Propert
 	    value->update (Builders::ColorBuilder::parse (valueIt), DynamicValue::UpdateSource::Initialization);
 	} else {
 	    std::string str = valueIt;
-	    int size = Builders::VectorBuilder::preparseSize (str);
+	    int size = isNumericVector (str) ? Builders::VectorBuilder::preparseSize (str) : 1;
 
 	    if (size == 1) {
 		std::size_t parsed = 0;
@@ -60,6 +187,13 @@ DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Propert
 	value->update (DynamicValue::UpdateSource::Initialization);
     }
 
+    if (data.is_object ()) {
+	if (const auto animation = data.find ("animation"); animation != data.end () && animation->is_object ()
+	    && animation->contains ("c0")) {
+	    value->setAnimation (parseAnimation (*animation));
+	}
+    }
+
     if (scriptSource.has_value ()) {
 	std::map<std::string, UserSettingUniquePtr> scriptProps;
 

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

@@ -120,6 +120,7 @@ SoundUniquePtr ObjectParser::parseSound (const JSON& it, const Project& project,
 	    .playbackmode = it.optional<std::string> ("playbackmode"),
 	    .sounds = sounds,
 	    .volume = it.user<float> ("volume", project.properties, 1.0f),
+	    .startsilent = it.optional<bool> ("startsilent"),
 	}
     );
 }
@@ -143,6 +144,11 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
 	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
 	    .padding = it.optional ("padding", glm::vec2 (0.0f)),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
+	    .limitWidth = it.user ("limitwidth", project.properties, false),
+	    .maxWidth = it.user ("maxwidth", project.properties, 500.0f),
+	    .limitRows = it.user ("limitrows", project.properties, false),
+	    .maxRows = it.user ("maxrows", project.properties, 1),
+	    .limitUseEllipsis = it.user ("limituseellipsis", project.properties, false),
 	}
     );
 }

+ 3 - 1
src/WallpaperEngine/Data/Parsers/PropertyParser.cpp

@@ -141,11 +141,13 @@ PropertySharedPtr PropertyParser::parseFile (const JSON& it, const std::string&
 }
 
 PropertySharedPtr PropertyParser::parseTextInput (const JSON& it, const std::string& name) {
+    const auto value = it.require ("value", "Property must have a value");
+
     return std::make_shared<PropertyTextInput> (
 	PropertyData {
 	    .name = name,
 	    .text = it.optional<std::string> ("text", ""),
 	},
-	it.require ("value", "Property must have a value").dump ()
+	value.is_string () ? value.get<std::string> () : value.dump ()
     );
 }

+ 84 - 3
src/WallpaperEngine/FileSystem/Adapters/Directory.cpp

@@ -1,3 +1,5 @@
+#include <algorithm>
+#include <cctype>
 #include <filesystem>
 #include <fstream>
 #include <memory>
@@ -9,8 +11,49 @@
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::FileSystem::Adapters;
 
+static bool equalsIgnoreCase (const std::string& a, const std::string& b) {
+    return a.size () == b.size ()
+	&& std::equal (a.begin (), a.end (), b.begin (), [] (unsigned char x, unsigned char y) {
+	       return std::tolower (x) == std::tolower (y);
+	   });
+}
+
+// scenes are authored on Windows, so missing path components fall back to a case-insensitive search
+static std::filesystem::path resolveCase (const std::filesystem::path& base, const std::filesystem::path& relative) {
+    std::error_code ec;
+
+    if (relative.is_absolute () || std::filesystem::exists (base / relative, ec)) {
+	return base / relative;
+    }
+
+    std::filesystem::path current = base;
+
+    for (const auto& part : relative) {
+	if (std::filesystem::exists (current / part, ec)) {
+	    current /= part;
+	    continue;
+	}
+
+	bool found = false;
+
+	for (const auto& entry : std::filesystem::directory_iterator (current, ec)) {
+	    if (equalsIgnoreCase (entry.path ().filename ().string (), part.string ())) {
+		current = entry.path ();
+		found = true;
+		break;
+	    }
+	}
+
+	if (!found) {
+	    return base / relative;
+	}
+    }
+
+    return current;
+}
+
 ReadStreamSharedPtr DirectoryAdapter::open (const std::filesystem::path& path) const {
-    auto finalpath = std::filesystem::canonical (this->basepath / path);
+    auto finalpath = std::filesystem::canonical (resolveCase (this->basepath, path));
 
     if (finalpath.string ().find (this->basepath.string ()) != 0) {
 	throw std::filesystem::filesystem_error ("Cannot find file", path, std::error_code ());
@@ -31,7 +74,7 @@ ReadStreamSharedPtr DirectoryAdapter::open (const std::filesystem::path& path) c
 
 bool DirectoryAdapter::exists (const std::filesystem::path& path) const {
     try {
-	const auto finalpath = std::filesystem::canonical (this->basepath / path);
+	const auto finalpath = std::filesystem::canonical (resolveCase (this->basepath, path));
 
 	if (finalpath.string ().find (this->basepath.string ()) != 0) {
 	    return false;
@@ -54,7 +97,7 @@ bool DirectoryAdapter::exists (const std::filesystem::path& path) const {
 }
 
 std::filesystem::path DirectoryAdapter::physicalPath (const std::filesystem::path& path) const {
-    auto finalpath = std::filesystem::canonical (this->basepath / path);
+    auto finalpath = std::filesystem::canonical (resolveCase (this->basepath, path));
 
     if (finalpath.string ().find (this->basepath.string ()) != 0) {
 	throw std::filesystem::filesystem_error ("Cannot find file", path, std::error_code ());
@@ -63,6 +106,44 @@ std::filesystem::path DirectoryAdapter::physicalPath (const std::filesystem::pat
     return finalpath;
 }
 
+std::optional<std::filesystem::path>
+DirectoryAdapter::resolveWorkshopDependencyAlias (const std::filesystem::path& path) const {
+    const std::filesystem::path dir = path.parent_path ();
+    const std::filesystem::path basename = path.filename ();
+    const std::filesystem::path workshopDir = this->basepath / dir / "workshop";
+
+    std::optional<std::filesystem::path> found = std::nullopt;
+
+    try {
+	if (!std::filesystem::exists (workshopDir) || !std::filesystem::is_directory (workshopDir)) {
+	    return std::nullopt;
+	}
+
+	for (const auto& entry : std::filesystem::directory_iterator (workshopDir)) {
+	    if (!entry.is_directory ()) {
+		continue;
+	    }
+
+	    const auto candidate = entry.path () / basename;
+
+	    if (!std::filesystem::exists (candidate) || !std::filesystem::is_regular_file (candidate)) {
+		continue;
+	    }
+
+	    if (found.has_value ()) {
+		// more than one dependency ships a same-named file - ambiguous, refuse to guess.
+		return std::nullopt;
+	    }
+
+	    found = dir / "workshop" / entry.path ().filename () / basename;
+	}
+    } catch (std::filesystem::filesystem_error&) {
+	return std::nullopt;
+    }
+
+    return found;
+}
+
 bool DirectoryFactory::handlesMountpoint (const std::filesystem::path& path) const {
     try {
 	const auto finalpath = std::filesystem::canonical (path);

+ 2 - 0
src/WallpaperEngine/FileSystem/Adapters/Directory.h

@@ -19,6 +19,8 @@ struct DirectoryAdapter final : Adapter {
     [[nodiscard]] ReadStreamSharedPtr open (const std::filesystem::path& path) const override;
     [[nodiscard]] bool exists (const std::filesystem::path& path) const override;
     [[nodiscard]] std::filesystem::path physicalPath (const std::filesystem::path& path) const override;
+    [[nodiscard]] std::optional<std::filesystem::path>
+    resolveWorkshopDependencyAlias (const std::filesystem::path& path) const override;
 
     const std::filesystem::path basepath;
 };

+ 40 - 10
src/WallpaperEngine/FileSystem/Adapters/Package.cpp

@@ -9,14 +9,30 @@
 #include "WallpaperEngine/Data/Utils/MemoryStream.h"
 
 #include <algorithm>
+#include <cctype>
 
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::FileSystem::Adapters;
 
+// scenes are authored on Windows, so fall back to a case-insensitive lookup when the exact path is missing
+static bool equalsIgnoreCase (const std::string& a, const std::string& b) {
+    return std::ranges::equal (a, b, [] (unsigned char x, unsigned char y) { return std::tolower (x) == std::tolower (y); });
+}
+
+template <typename Files>
+static auto findFile (const Files& files, const std::filesystem::path& path) {
+    const auto wanted = path.string ();
+    const auto exact = std::ranges::find_if (files, [&wanted] (const auto& file) { return file->filename == wanted; });
+
+    if (exact != files.end ()) {
+	return exact;
+    }
+
+    return std::ranges::find_if (files, [&wanted] (const auto& file) { return equalsIgnoreCase (file->filename, wanted); });
+}
+
 ReadStreamSharedPtr PackageAdapter::open (const std::filesystem::path& path) const {
-    const auto it = std::ranges::find_if (this->package->files, [&path] (const auto& file) {
-	return file->filename == path.string ();
-    });
+    const auto it = findFile (this->package->files, path);
 
     if (it == this->package->files.end ()) {
 	throw std::filesystem::filesystem_error ("Cannot find file", path, std::error_code ());
@@ -31,19 +47,33 @@ ReadStreamSharedPtr PackageAdapter::open (const std::filesystem::path& path) con
 }
 
 bool PackageAdapter::exists (const std::filesystem::path& path) const {
-    for (const auto& file : this->package->files) {
-	if (file->filename == path.string ()) {
-	    return true;
-	}
-    }
-
-    return false;
+    return findFile (this->package->files, path) != this->package->files.end ();
 }
 
 std::filesystem::path PackageAdapter::physicalPath (const std::filesystem::path& path) const {
     throw std::filesystem::filesystem_error ("Package adapter does not support realpath", path, std::error_code ());
 }
 
+std::optional<std::filesystem::path>
+PackageAdapter::resolveWorkshopDependencyAlias (const std::filesystem::path& path) const {
+    std::optional<std::filesystem::path> found = std::nullopt;
+
+    for (const auto& file : this->package->files) {
+	if (!isWorkshopDependencyAliasOf (file->filename, path)) {
+	    continue;
+	}
+
+	if (found.has_value ()) {
+	    // more than one dependency ships a same-named file under this path, refuse to guess
+	    return std::nullopt;
+	}
+
+	found = file->filename;
+    }
+
+    return found;
+}
+
 bool PackageFactory::handlesMountpoint (const std::filesystem::path& path) const {
     try {
 	const auto finalpath = std::filesystem::canonical (path);

+ 2 - 0
src/WallpaperEngine/FileSystem/Adapters/Package.h

@@ -23,6 +23,8 @@ struct PackageAdapter final : Adapter {
     [[nodiscard]] ReadStreamSharedPtr open (const std::filesystem::path& path) const override;
     [[nodiscard]] bool exists (const std::filesystem::path& path) const override;
     [[nodiscard]] std::filesystem::path physicalPath (const std::filesystem::path& path) const override;
+    [[nodiscard]] std::optional<std::filesystem::path>
+    resolveWorkshopDependencyAlias (const std::filesystem::path& path) const override;
 
     PackageUniquePtr package;
 };

+ 26 - 0
src/WallpaperEngine/FileSystem/Adapters/Types.h

@@ -2,9 +2,29 @@
 
 #include "WallpaperEngine/Data/Utils/BinaryReader.h"
 #include <filesystem>
+#include <optional>
+#include <string>
 
 namespace WallpaperEngine::FileSystem::Adapters {
 using namespace WallpaperEngine::Data::Utils;
+
+// Whether candidate is wanted with one "workshop/<anything>" segment inserted before the basename.
+// Scripts reference bundled workshop-dependency assets by their original, un-prefixed name.
+inline bool isWorkshopDependencyAliasOf (const std::string& candidate, const std::filesystem::path& wanted) {
+    const std::string dir = wanted.parent_path ().generic_string ();
+    const std::string basename = wanted.filename ().string ();
+    const std::string prefix = (dir.empty () ? std::string () : dir + "/") + "workshop/";
+    const std::string suffix = "/" + basename;
+
+    if (candidate.size () <= prefix.size () + suffix.size () || !candidate.starts_with (prefix)
+	|| !candidate.ends_with (suffix)) {
+	return false;
+    }
+
+    const std::string middle = candidate.substr (prefix.size (), candidate.size () - prefix.size () - suffix.size ());
+    return !middle.empty () && middle.find ('/') == std::string::npos;
+}
+
 struct Adapter {
     Adapter () = default;
     virtual ~Adapter () = default;
@@ -12,6 +32,12 @@ struct Adapter {
     [[nodiscard]] virtual ReadStreamSharedPtr open (const std::filesystem::path& path) const = 0;
     [[nodiscard]] virtual bool exists (const std::filesystem::path& path) const = 0;
     [[nodiscard]] virtual std::filesystem::path physicalPath (const std::filesystem::path& path) const = 0;
+
+    /** Finds a workshop-dependency-prefixed variant of path in this adapter, only when exactly one matches */
+    [[nodiscard]] virtual std::optional<std::filesystem::path>
+    resolveWorkshopDependencyAlias (const std::filesystem::path& path) const {
+	return std::nullopt;
+    }
 };
 
 using AdapterSharedPtr = std::shared_ptr<Adapter>;

+ 26 - 1
src/WallpaperEngine/FileSystem/Container.cpp

@@ -98,4 +98,29 @@ Adapter& Container::resolveAdapterForFile (const std::filesystem::path& path) co
     );
 }
 
-void Container::registerAdapterFactory (FactoryUniquePtr factory) { this->m_factories.push_back (std::move (factory)); }
+void Container::registerAdapterFactory (FactoryUniquePtr factory) { this->m_factories.push_back (std::move (factory)); }
+
+std::optional<std::filesystem::path>
+Container::resolveWorkshopDependencyAlias (const std::filesystem::path& path) const {
+    const auto normalized = normalize_path (path);
+
+    for (const auto& [root, adapter] : this->m_mountpoints) {
+	if (!normalized.string ().starts_with (root.string ())) {
+	    continue;
+	}
+
+	const auto relative = normalized.string ().substr (root.string ().length ());
+
+	if (const auto found = adapter->resolveWorkshopDependencyAlias (relative); found.has_value ()) {
+	    // relative to the mountpoint root, without a leading "/"
+	    return *found;
+	}
+    }
+
+    // relative paths from scripts never match the absolute mountpoint roots, so retry with the root prepended
+    if (!normalized.string ().starts_with ("/")) {
+	return this->resolveWorkshopDependencyAlias ("/" + normalized.string ());
+    }
+
+    return std::nullopt;
+}

+ 4 - 0
src/WallpaperEngine/FileSystem/Container.h

@@ -57,6 +57,10 @@ public:
      */
     void registerAdapterFactory (FactoryUniquePtr factory);
 
+    /** Looks for a workshop-dependency-prefixed variant of path across every mounted adapter, only as a fallback when the literal path fails */
+    [[nodiscard]] std::optional<std::filesystem::path> resolveWorkshopDependencyAlias (const std::filesystem::path& path
+    ) const;
+
 private:
     /**
      * Searches for an adapter to handle the given file

+ 129 - 0
src/WallpaperEngine/Media/ThumbnailPalette.cpp

@@ -0,0 +1,129 @@
+#include "ThumbnailPalette.h"
+
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <vector>
+
+using namespace WallpaperEngine::Media;
+
+namespace {
+constexpr int kBucketBits = 4;
+constexpr int kBucketsPerChannel = 1 << kBucketBits;
+// colors closer than this (euclidean distance in 0-1 RGB) count as the same color
+constexpr float kMinDistinctDistance = 0.28f;
+
+struct Bucket {
+    uint32_t count = 0;
+    glm::vec3 sum = { 0.0f, 0.0f, 0.0f };
+
+    [[nodiscard]] glm::vec3 average () const { return sum / static_cast<float> (count); }
+};
+
+float luminance (const glm::vec3& color) {
+    const auto linear = [] (float channel) {
+	return channel <= 0.03928f ? channel / 12.92f : std::pow ((channel + 0.055f) / 1.055f, 2.4f);
+    };
+
+    return 0.2126f * linear (color.r) + 0.7152f * linear (color.g) + 0.0722f * linear (color.b);
+}
+
+float contrastRatio (const glm::vec3& a, const glm::vec3& b) {
+    const float la = luminance (a);
+    const float lb = luminance (b);
+
+    return (std::max (la, lb) + 0.05f) / (std::min (la, lb) + 0.05f);
+}
+} // namespace
+
+ThumbnailPalette WallpaperEngine::Media::computeThumbnailPalette (const uint8_t* rgba, size_t width, size_t height) {
+    ThumbnailPalette palette;
+
+    if (rgba == nullptr || width == 0 || height == 0) {
+	return palette;
+    }
+
+    std::array<Bucket, kBucketsPerChannel * kBucketsPerChannel * kBucketsPerChannel> buckets;
+
+    // looking at every pixel of a 3000px cover is pointless, a ~96x96 grid is plenty
+    const size_t stepX = std::max<size_t> (1, width / 96);
+    const size_t stepY = std::max<size_t> (1, height / 96);
+
+    for (size_t y = 0; y < height; y += stepY) {
+	for (size_t x = 0; x < width; x += stepX) {
+	    const uint8_t* pixel = rgba + (y * width + x) * 4;
+
+	    if (pixel[3] < 128) {
+		continue;
+	    }
+
+	    const size_t index = ((static_cast<size_t> (pixel[0]) >> (8 - kBucketBits)) * kBucketsPerChannel
+				  + (static_cast<size_t> (pixel[1]) >> (8 - kBucketBits)))
+		    * kBucketsPerChannel
+		+ (static_cast<size_t> (pixel[2]) >> (8 - kBucketBits));
+
+	    buckets[index].count++;
+	    buckets[index].sum += glm::vec3 (pixel[0], pixel[1], pixel[2]) / 255.0f;
+	}
+    }
+
+    std::vector<const Bucket*> ordered;
+
+    for (const auto& bucket : buckets) {
+	if (bucket.count > 0) {
+	    ordered.push_back (&bucket);
+	}
+    }
+
+    if (ordered.empty ()) {
+	return palette;
+    }
+
+    std::ranges::sort (ordered, [] (const Bucket* a, const Bucket* b) { return a->count > b->count; });
+
+    std::vector<glm::vec3> picked;
+
+    for (const Bucket* bucket : ordered) {
+	const glm::vec3 color = bucket->average ();
+	const bool distinct = std::ranges::all_of (picked, [&color] (const glm::vec3& other) {
+	    return glm::distance (color, other) >= kMinDistinctDistance;
+	});
+
+	if (distinct) {
+	    picked.push_back (color);
+	}
+
+	if (picked.size () == 3) {
+	    break;
+	}
+    }
+
+    // flat covers only have one or two distinct colors, reuse what there is
+    while (picked.size () < 3) {
+	picked.push_back (picked.back ());
+    }
+
+    palette.primary = picked[0];
+    palette.secondary = picked[1];
+    palette.tertiary = picked[2];
+
+    const glm::vec3 white (1.0f);
+    const glm::vec3 black (0.0f);
+
+    palette.highContrast = contrastRatio (palette.primary, white) >= contrastRatio (palette.primary, black) ? white : black;
+    palette.text = palette.highContrast;
+
+    // a color from the cover itself is nicer than plain black/white as long as it is readable
+    float bestContrast = 4.5f;
+
+    for (const glm::vec3& candidate : { palette.secondary, palette.tertiary }) {
+	const float contrast = contrastRatio (palette.primary, candidate);
+
+	if (contrast > bestContrast) {
+	    bestContrast = contrast;
+	    palette.text = candidate;
+	}
+    }
+
+    return palette;
+}

+ 28 - 0
src/WallpaperEngine/Media/ThumbnailPalette.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include <glm/vec3.hpp>
+#include <glm/geometric.hpp>
+
+namespace WallpaperEngine::Media {
+/**
+ * The colors scripts receive with a media thumbnail event.
+ */
+struct ThumbnailPalette {
+    glm::vec3 primary = { 0.12f, 0.12f, 0.12f };
+    glm::vec3 secondary = { 0.0f, 0.0f, 0.0f };
+    glm::vec3 tertiary = { 0.25f, 0.25f, 0.25f };
+    /** legible on top of the primary color */
+    glm::vec3 text = { 1.0f, 1.0f, 1.0f };
+    /** black or white, whichever stands out most against the primary color */
+    glm::vec3 highContrast = { 1.0f, 1.0f, 1.0f };
+};
+
+/**
+ * Picks the dominant colors of an RGBA8 image: the most common colors that are visibly different
+ * from each other, most common first.
+ */
+ThumbnailPalette computeThumbnailPalette (const uint8_t* rgba, size_t width, size_t height);
+} // namespace WallpaperEngine::Media

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

@@ -77,6 +77,8 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 		    glPixelStorei (GL_UNPACK_ALIGNMENT, 1);
 		    textureFormat = GL_RED;
 		} else if (this->m_header->format == TextureFormat_RG88) {
+		    // 2 bytes per pixel, so odd widths are not 4-byte aligned
+		    glPixelStorei (GL_UNPACK_ALIGNMENT, 1);
 		    textureFormat = GL_RG;
 		}
 	    }

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

@@ -3,6 +3,7 @@
 #include "CWallpaper.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Render/Wallpapers/CSplat.h"
 #include "WallpaperEngine/Render/Wallpapers/CVideo.h"
 #include "WallpaperEngine/Render/Wallpapers/CWeb.h"
 
@@ -321,6 +322,8 @@ void CWallpaper::setScalingMode (WallpaperState::TextureUVsScaling mode) { this-
 
 void CWallpaper::setZoom (float zoom) { this->m_state.setZoom (zoom); }
 
+void CWallpaper::setOffset (float offsetX, float offsetY) { this->m_state.setOffset (offsetX, offsetY); }
+
 void CWallpaper::setCornerColor (const glm::vec4& color) {
     this->m_cornerColor = color;
 
@@ -358,6 +361,17 @@ std::unique_ptr<CWallpaper> CWallpaper::fromWallpaper (
 	);
     }
 
+    // SOG depth wallpapers draw the splat data natively, the CEF viewer renders badly (see CSplat)
+    if (wallpaper.is<Web> () && WallpaperEngine::Render::Wallpapers::CSplat::supports (wallpaper.project)) {
+	try {
+	    return std::make_unique<WallpaperEngine::Render::Wallpapers::CSplat> (
+		wallpaper, context, audioContext, scalingMode, clampMode
+	    );
+	} catch (const std::exception& e) {
+	    sLog.error ("Native splat renderer failed, falling back to the web page: ", e.what ());
+	}
+    }
+
     if (wallpaper.is<Web> ()) {
 	return std::make_unique<WallpaperEngine::Render::Wallpapers::CWeb> (
 	    wallpaper, context, audioContext, resolvedBackgroundPath, scalingMode, clampMode, maxRenderSize

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

@@ -70,6 +70,9 @@ public:
     /** Changes the manual zoom factor live, without reloading the wallpaper (used by the hotswap control file) */
     void setZoom (float zoom);
 
+    /** Re-centers a cropping scaling mode's visible window live (see WallpaperState::setOffset) */
+    void setOffset (float offsetX, float offsetY);
+
     /**
      * Changes the color shown outside the wallpaper's bounds (Center/Fit letterboxing, zoomed-out
      * scaling) live, without reloading the wallpaper. Only visible when the clamp mode is border

+ 293 - 34
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -283,7 +283,8 @@ std::optional<double> scorePuppetMeshCoherence (const PuppetMeshData& data) {
 // plausible stride is tried against every candidate mesh header found in the file, and whichever
 // combination produces the most coherent triangulated mesh wins.
 std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
-    const BinaryReader& reader, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize
+    const BinaryReader& reader, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize,
+    const std::string& debugName
 ) {
     constexpr size_t minVertexStride = 20; // position (12 bytes) + uv (8 bytes), no bone data at all
     constexpr size_t maxVertexStride = 256; // generous upper bound, comfortably covers multi-bone rigs
@@ -294,6 +295,9 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
     std::optional<PuppetVertexLayout> best;
     double bestScore = std::numeric_limits<double>::max ();
 
+    // TEMP-DIAG: score of every (block, stride) attempt
+    std::vector<std::tuple<double, size_t, size_t, size_t>> allScored;
+
     for (const auto& block : candidates) {
 	for (size_t stride = minVertexStride; stride <= maxVertexStride; stride += strideStep) {
 	    const auto data = readPuppetMeshData (reader, block, meshHeaderSize, stride);
@@ -302,7 +306,15 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
 	    }
 
 	    const auto score = scorePuppetMeshCoherence (*data);
-	    if (!score.has_value () || *score >= bestScore) {
+	    if (!score.has_value ()) {
+		continue;
+	    }
+
+	    if (!debugName.empty ()) {
+		allScored.emplace_back (*score, block.headerOffset, stride, data->positions.size () / 3);
+	    }
+
+	    if (*score >= bestScore) {
 		continue;
 	    }
 
@@ -311,6 +323,18 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
 	}
     }
 
+    if (!debugName.empty ()) {
+	std::sort (allScored.begin (), allScored.end ());
+	sLog.out ("TEMP-DIAG vertex layout candidates for ", debugName, ": total=", allScored.size ());
+	for (size_t i = 0; i < allScored.size () && i < 10; i++) {
+	    const auto& [score, headerOffset, stride, vertexCount] = allScored[i];
+	    sLog.out (
+		"TEMP-DIAG   #", i, " score=", score, " headerOffset=", headerOffset, " stride=", stride,
+		" vertexCount=", vertexCount
+	    );
+	}
+    }
+
     return best;
 }
 
@@ -348,7 +372,8 @@ PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
     result.bones.reserve (boneCount);
 
     for (uint32_t i = 0; i < boneCount; i++) {
-	(void) reader.next (); // tmp byte, unused
+	// records start with a null-terminated name, empty for most rigs
+	(void) reader.nextNullTerminatedString ();
 	(void) reader.nextUInt32 (); // type, unused
 	const int parent = reader.nextInt ();
 	const uint32_t matrixBytes = reader.nextUInt32 ();
@@ -379,7 +404,8 @@ PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
 	    reader.base ().seekg (static_cast<std::streamoff> (matrixBytes), std::ios::cur);
 	}
 
-	(void) reader.nextNullTerminatedString (); // bone name, unused (always empty in every sample seen)
+	// trailing per-bone string, jiggle/physics JSON for some rigs
+	(void) reader.nextNullTerminatedString ();
 
 	result.bones.push_back (PuppetBone { .parent = parent, .bindLocal = bindLocal });
     }
@@ -624,6 +650,25 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 		keyframe.scale = { reader.nextFloat (), reader.nextFloat (), reader.nextFloat () };
 		track.push_back (keyframe);
 	    }
+
+	    // TEMP-DIAG: raw keyframe dump for bone 29
+	    if (boneIndex == 29 && expectedBoneCount > 29) {
+		float minRotZ = std::numeric_limits<float>::max (), maxRotZ = std::numeric_limits<float>::lowest ();
+		float minPosX = std::numeric_limits<float>::max (), maxPosX = std::numeric_limits<float>::lowest ();
+		for (const auto& kf : track) {
+		    minRotZ = std::min (minRotZ, kf.rotation.z);
+		    maxRotZ = std::max (maxRotZ, kf.rotation.z);
+		    minPosX = std::min (minPosX, kf.position.x);
+		    maxPosX = std::max (maxPosX, kf.position.x);
+		}
+		sLog.out (
+		    "TEMP-DIAG bone29 raw track for clip '", clip.name, "': samples=", track.size (), " rotZ=[",
+		    minRotZ, ",", maxRotZ, "] posX=[", minPosX, ",", maxPosX, "] first3=(",
+		    track.size () > 0 ? track[0].rotation.z : 0.0f, ",", track.size () > 1 ? track[1].rotation.z : 0.0f,
+		    ",", track.size () > 2 ? track[2].rotation.z : 0.0f, ") mid=(",
+		    track.size () > 90 ? track[90].rotation.z : 0.0f, ")"
+		);
+	    }
 	}
 
 	clips.push_back (std::move (clip));
@@ -653,13 +698,7 @@ CImage::ResolvedTransform CImage::localTransform (const Object& object) {
 	scale = image->scale->value->getVec3 ();
 	angle = image->angles->value->getVec3 ().z;
 
-	// cropoffset corrects autosize's canvas centering against the content's real pivot. Only
-	// applies to puppets - non-puppet autosized images already have it baked into their own
-	// "origin" by the editor, so applying it again doubles the shift.
-	if (image->model->cropOffset.has_value () && image->model->puppet.has_value ()) {
-	    origin.x += image->model->cropOffset->x;
-	    origin.y += image->model->cropOffset->y;
-	}
+	// cropoffset is already baked into the object's origin, adding it again shifts the layer
     } else if (object.is<Text> ()) {
 	const auto* text = object.as<Text> ();
 	scale = text->scale->value->getVec3 ();
@@ -697,6 +736,7 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
     // Accumulate top-down: the root's local transform is already its resolved
     // transform, then fold each child onto its already-resolved parent.
     ResolvedTransform resolved = localTransform (*chain[count - 1]);
+    float meshPivotAngle = 0.0f;
     for (int i = count - 2; i >= 0; --i) {
 	ResolvedTransform local = localTransform (*chain[i]);
 
@@ -749,6 +789,7 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	// last state confirmed actually visible (if wrongly rotated) on real hardware - a real fix for the
 	// rotation needs to explain why a *different* angle would still hit the same crease, not just look
 	// better in isolation.
+	// meshPivotAngle: the remaining angle difference pivots around the mesh's own center, not the object origin
 	glm::vec3 anchorOrigin = resolved.origin;
 	float anchorAngle = resolved.angle;
 	glm::vec2 anchorScale = { 1.0f, 1.0f };
@@ -767,6 +808,9 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 		    // the bone's own scale (possibly negative, i.e. a mirrored bone) carries into whatever
 		    // rides it, same as position/rotation
 		    anchorScale = meshTransform->scale;
+		    // the attachment matrix carries a static rotation that only orients the point's own frame,
+		    // so it steers the child's offset but not its orientation, and is cancelled around the mesh center
+		    meshPivotAngle += -meshTransform->restAngle;
 
 		    if (!this->m_attachmentDiagnosticLogged.contains (chain[i]->id)) {
 			this->m_attachmentDiagnosticLogged.insert (chain[i]->id);
@@ -777,7 +821,7 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 			    " boneScale=(", meshTransform->scale.x, ",", meshTransform->scale.y, ") parentOrigin=(",
 			    resolved.origin.x, ",", resolved.origin.y, ") parentScale=", resolved.scale.x,
 			    " anchorOrigin=(", anchorOrigin.x, ",", anchorOrigin.y, ") anchorAngleDeg=",
-			    glm::degrees (anchorAngle)
+			    glm::degrees (anchorAngle), " restAngleDeg=", glm::degrees (meshTransform->restAngle)
 			);
 		    }
 		}
@@ -791,7 +835,7 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	local.origin.z = resolved.origin.z + local.origin.z * resolved.scale.z;
 	local.scale.x *= anchorScale.x;
 	local.scale.y *= anchorScale.y;
-	resolved = { local.origin, local.scale * resolved.scale, local.angle + anchorAngle };
+	resolved = { local.origin, local.scale * resolved.scale, local.angle + anchorAngle, meshPivotAngle };
 
 	if ((chain[i]->id == 422 || chain[i]->id == 134) && !this->m_finalOriginLogged.contains (chain[i]->id)) {
 	    this->m_finalOriginLogged.insert (chain[i]->id);
@@ -820,6 +864,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->registerEffectConstants (image.effects);
 
     auto scene_width = static_cast<float> (scene.getWidth ());
     auto scene_height = static_cast<float> (scene.getHeight ());
@@ -903,8 +948,10 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     // scene.json's own "clampuvs" is a per-object override on top of whatever the base texture
     // asset defaults to - without it, effects that distort UVs near the edges (refraction, ripples)
     // can wrap around and sample the opposite edge of the buffer instead of clamping.
-    const uint32_t compositeFlags
-	= this->getImage ().clampUVs ? (this->m_texture->getFlags () | TextureFlags_ClampUVs) : this->m_texture->getFlags ();
+    // compose layers always clamp, their effects would otherwise wrap samples from the opposite edge
+    const uint32_t compositeFlags = (this->getImage ().clampUVs || this->getImage ().model->passthrough)
+	? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
+	: this->m_texture->getFlags ();
 
     this->m_currentMainFBO = this->m_mainFBO = scene.create (
 	nameA.str (), TextureFormat_ARGB8888, compositeFlags, 1, { size.x, size.y }, { size.x, size.y }
@@ -1083,7 +1130,11 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	std::copy (data.begin (), data.end (), meshBuffer.get ());
 	const BinaryReader reader (std::make_shared<MemoryStream> (std::move (meshBuffer), data.size ()));
 
-	const auto layout = resolvePuppetVertexLayout (reader, markerSize, mdlsOffset, meshHeaderSize);
+	const bool isDiagTarget
+	    = this->getImage ().name == "bodyhairkochuru" || this->getImage ().name == "spiritblossomahribase";
+	const auto layout = resolvePuppetVertexLayout (
+	    reader, markerSize, mdlsOffset, meshHeaderSize, isDiagTarget ? this->getImage ().name : ""
+	);
 	if (!layout.has_value ()) {
 	    sLog.error ("Could not find a usable MDLV mesh block in ", *this->getImage ().model->puppet);
 	    return false;
@@ -1109,6 +1160,55 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	);
 
 	this->m_puppetIndexCount = static_cast<GLsizei> (mesh->indices.size ());
+
+	if (isDiagTarget) {
+	    this->m_puppetTexCoordData = mesh->texcoords;
+	    this->m_puppetIndicesData = mesh->indices;
+	}
+
+	// TEMP-DIAG: per-triangle UV-vs-position area ratio
+	if (isDiagTarget) {
+	    std::vector<double> ratios;
+	    ratios.reserve (mesh->indices.size () / 3);
+	    for (size_t t = 0; t + 2 < mesh->indices.size (); t += 3) {
+		const auto i0 = mesh->indices[t], i1 = mesh->indices[t + 1], i2 = mesh->indices[t + 2];
+		const glm::vec2 p0 (mesh->positions[i0 * 3], mesh->positions[i0 * 3 + 1]);
+		const glm::vec2 p1 (mesh->positions[i1 * 3], mesh->positions[i1 * 3 + 1]);
+		const glm::vec2 p2 (mesh->positions[i2 * 3], mesh->positions[i2 * 3 + 1]);
+		const glm::vec2 u0 (mesh->texcoords[i0 * 2], mesh->texcoords[i0 * 2 + 1]);
+		const glm::vec2 u1 (mesh->texcoords[i1 * 2], mesh->texcoords[i1 * 2 + 1]);
+		const glm::vec2 u2 (mesh->texcoords[i2 * 2], mesh->texcoords[i2 * 2 + 1]);
+		const double posArea = std::abs ((p1.x - p0.x) * (p2.y - p0.y) - (p2.x - p0.x) * (p1.y - p0.y));
+		const double uvArea = std::abs ((u1.x - u0.x) * (u2.y - u0.y) - (u2.x - u0.x) * (u1.y - u0.y));
+		if (posArea <= 1e-6) {
+		    continue;
+		}
+		ratios.push_back (uvArea / posArea);
+	    }
+	    std::vector<double> sorted = ratios;
+	    std::sort (sorted.begin (), sorted.end ());
+	    const double median = sorted.empty () ? 0.0 : sorted[sorted.size () / 2];
+	    sLog.out (
+		"TEMP-DIAG uv/pos area ratio for ", this->getImage ().name, ": triCount=", ratios.size (), " median=", median
+	    );
+	    for (size_t t = 0; t + 2 < mesh->indices.size (); t += 3) {
+		const size_t triIndex = t / 3;
+		if (triIndex >= ratios.size ()) {
+		    break;
+		}
+		const double ratio = ratios[triIndex];
+		if (median > 0.0 && (ratio > median * 20.0 || ratio < median / 20.0)) {
+		    const auto i0 = mesh->indices[t], i1 = mesh->indices[t + 1], i2 = mesh->indices[t + 2];
+		    sLog.out (
+			"TEMP-DIAG   outlier tri=", triIndex, " ratio=", ratio, " verts=(", i0, ",", i1, ",", i2,
+			") uv0=(", mesh->texcoords[i0 * 2], ",", mesh->texcoords[i0 * 2 + 1], ") uv1=(",
+			mesh->texcoords[i1 * 2], ",", mesh->texcoords[i1 * 2 + 1], ") uv2=(", mesh->texcoords[i2 * 2],
+			",", mesh->texcoords[i2 * 2 + 1], ")"
+		    );
+		}
+	    }
+	}
+
 	sLog.out (
 	    "Loaded puppet mesh ", *this->getImage ().model->puppet, " version=", puppetVersion, " stride=",
 	    layout->vertexStride, " vertices=", this->m_puppetRawPositions.size () / 3, " indices=", this->m_puppetIndexCount
@@ -1264,7 +1364,7 @@ void CImage::updatePuppetPositionBuffer (const glm::vec2& size) {
     // absolute scene-space coordinates uploadGeometryBuffers() bakes into sceneSpacePosition for a
     // normal quad, or every vertex renders shifted by a constant offset equal to wherever this object
     // should have been, reading as the whole mesh floating somewhere else on screen entirely.
-    const bool bakeScenePosition = this->m_passes.size () <= 1;
+    const bool bakeScenePosition = this->m_passes.size () <= 1 || this->m_puppetMeshLast;
 
     std::vector<GLfloat> positions;
     positions.reserve (source.size ());
@@ -1351,9 +1451,17 @@ void CImage::updatePuppetSkinning () {
 
 	float frameFloat = 0.0f;
 	if (duration > 0.0f) {
-	    float elapsed = std::fmod (g_Time * rate, duration);
+	    // "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) {
+		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 += duration;
+		elapsed += period;
+	    }
+	    if (mirror && elapsed > duration) {
+		elapsed = period - elapsed;
 	    }
 	    frameFloat = elapsed * clip.fps;
 	}
@@ -1377,6 +1485,7 @@ void CImage::updatePuppetSkinning () {
 
 	const glm::vec3 bindPosition (bone.bindLocal[3]);
 	glm::vec3 position = bindPosition;
+	bool positionBased = false;
 	glm::vec3 rotation (0.0f);
 	glm::vec3 scale (1.0f);
 	bool anyTrack = false;
@@ -1394,7 +1503,13 @@ void CImage::updatePuppetSkinning () {
 	    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);
 
-	    position += sample.blend * (trackPosition - bindPosition);
+	    // 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;
+	    }
+	    position += sample.blend * (trackPosition - restPosition);
 	    rotation += sample.blend * trackRotation;
 	    scale += sample.blend * (trackScale - glm::vec3 (1.0f));
 	}
@@ -1407,13 +1522,14 @@ void CImage::updatePuppetSkinning () {
 
 	animatedLocals[i] = local;
 
-	if (!this->m_boneTrackDiagLogged) {
+	// TEMP-DIAG: bones 29/30 logged every frame
+	if (!this->m_boneTrackDiagLogged || ((i == 29 || i == 30) && this->getImage ().name == "bodyhairkochuru")) {
 	    sLog.out (
 		"TEMP-DIAG bone anim for ", this->getImage ().name, " (", this->getId (), ") i=", i, " parent=",
 		bone.parent, " bindLocalPos=(", bone.bindLocal[3].x, ",", bone.bindLocal[3].y, ") animatedPos=(",
 		position.x, ",", position.y, ",", position.z, ") rotationDeg=(", glm::degrees (rotation.x), ",",
 		glm::degrees (rotation.y), ",", glm::degrees (rotation.z), ") scale=(", scale.x, ",", scale.y, ",",
-		scale.z, ") hasTrack=", anyTrack, " activeLayers=", samples.size ()
+		scale.z, ") hasTrack=", anyTrack, " activeLayers=", samples.size (), " time=", g_Time
 	    );
 	}
     }
@@ -1463,6 +1579,61 @@ void CImage::updatePuppetSkinning () {
 	this->m_puppetSkinnedPositions[v * 3 + 2] = skinned.z;
     }
 
+    // TEMP-DIAG: triangles that overlap on screen post-skinning despite sampling distant UV regions
+    if (!this->m_puppetOverlapDiagLogged && !this->m_puppetIndicesData.empty ()
+        && (this->getImage ().name == "bodyhairkochuru" || this->getImage ().name == "spiritblossomahribase")) {
+	this->m_puppetOverlapDiagLogged = true;
+
+	struct TriBounds {
+	    glm::vec2 min, max, uvCentroid;
+	};
+	std::vector<TriBounds> tris;
+	const size_t triCount = this->m_puppetIndicesData.size () / 3;
+	tris.reserve (triCount);
+
+	for (size_t t = 0; t < triCount; t++) {
+	    const auto i0 = this->m_puppetIndicesData[t * 3];
+	    const auto i1 = this->m_puppetIndicesData[t * 3 + 1];
+	    const auto i2 = this->m_puppetIndicesData[t * 3 + 2];
+	    const glm::vec2 p0 (this->m_puppetSkinnedPositions[i0 * 3], this->m_puppetSkinnedPositions[i0 * 3 + 1]);
+	    const glm::vec2 p1 (this->m_puppetSkinnedPositions[i1 * 3], this->m_puppetSkinnedPositions[i1 * 3 + 1]);
+	    const glm::vec2 p2 (this->m_puppetSkinnedPositions[i2 * 3], this->m_puppetSkinnedPositions[i2 * 3 + 1]);
+	    const glm::vec2 uv0 (this->m_puppetTexCoordData[i0 * 2], this->m_puppetTexCoordData[i0 * 2 + 1]);
+	    const glm::vec2 uv1 (this->m_puppetTexCoordData[i1 * 2], this->m_puppetTexCoordData[i1 * 2 + 1]);
+	    const glm::vec2 uv2 (this->m_puppetTexCoordData[i2 * 2], this->m_puppetTexCoordData[i2 * 2 + 1]);
+	    tris.push_back (TriBounds {
+		.min = glm::min (p0, glm::min (p1, p2)), .max = glm::max (p0, glm::max (p1, p2)),
+		.uvCentroid = (uv0 + uv1 + uv2) / 3.0f });
+	}
+
+	size_t overlapCount = 0;
+	for (size_t a = 0; a < triCount && overlapCount < 15; a++) {
+	    for (size_t b = a + 1; b < triCount && overlapCount < 15; b++) {
+		const auto& ta = tris[a];
+		const auto& tb = tris[b];
+		const bool boxesOverlap
+		    = ta.min.x <= tb.max.x && ta.max.x >= tb.min.x && ta.min.y <= tb.max.y && ta.max.y >= tb.min.y;
+		if (!boxesOverlap) {
+		    continue;
+		}
+		if (glm::distance (ta.uvCentroid, tb.uvCentroid) < 0.15f) {
+		    continue;
+		}
+		overlapCount++;
+		sLog.out (
+		    "TEMP-DIAG overlap for ", this->getImage ().name, " tri", a, " box=(", ta.min.x, ",", ta.min.y, ")-(",
+		    ta.max.x, ",", ta.max.y, ") uv=(", ta.uvCentroid.x, ",", ta.uvCentroid.y, ")  vs  tri", b, " box=(",
+		    tb.min.x, ",", tb.min.y, ")-(", tb.max.x, ",", tb.max.y, ") uv=(", tb.uvCentroid.x, ",", tb.uvCentroid.y,
+		    ")"
+		);
+	    }
+	}
+	sLog.out (
+	    "TEMP-DIAG overlap scan for ", this->getImage ().name, " done: triCount=", triCount, " overlapsLogged=",
+	    overlapCount
+	);
+    }
+
     this->updatePuppetPositionBuffer (this->m_size);
 }
 
@@ -1494,7 +1665,12 @@ std::optional<CImage::AttachmentPointTransform> CImage::getAttachmentPointMeshTr
 	  )
 			 : glm::vec2 (scaleX, glm::length (glm::vec2 (animatedWorld[1])));
 
-    return AttachmentPointTransform { .position = glm::vec3 (animatedWorld[3]), .angle = angle, .scale = scale };
+    const glm::mat4 bindWorld = glm::inverse (this->m_puppetBones[it->boneIndex].inverseBindWorld) * it->localTransform;
+    const float restAngle = std::atan2 (bindWorld[0][1], bindWorld[0][0]);
+
+    return AttachmentPointTransform {
+	.position = glm::vec3 (animatedWorld[3]), .angle = angle, .scale = scale, .restAngle = restAngle
+    };
 }
 
 void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
@@ -1764,15 +1940,24 @@ void CImage::setup () {
 		continue;
 	    }
 
-	    // non-visible effects are skipped, though some may still affect output despite being invisible
-	    if (!cur->visible->value->getBool ()) {
+	    const auto effectVisibility = this->getScene ().getContext ().getApp ().getContext ().resolveEffectVisibility (
+		static_cast<int> (cur->id), cur->name
+	    );
+
+	    // an explicit --disable-effect/--enable-effect override wins over the scene's own visibility
+	    if (effectVisibility.has_value () ? !*effectVisibility : !cur->visible->value->getBool ()) {
 		continue;
 	    }
 
 	    const auto fboProvider = std::make_shared<FBOProvider> (this);
 
 	    for (const auto& fbo : cur->effect->fbos) {
-		fboProvider->create (*fbo, this->m_texture->getFlags (), this->getSize ());
+		fboProvider->create (
+		    *fbo,
+		    this->m_image.model->passthrough ? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
+						      : this->m_texture->getFlags (),
+		    this->getSize ()
+		);
 	    }
 
 	    // TODO: MAKE USE OF ZIP OPERATOR IN BOOST? WAY OVERKILL JUST FOR THIS...
@@ -1841,6 +2026,8 @@ void CImage::setup () {
 	}
     }
 
+    const size_t passCountBeforeTrailingPasses = this->m_passes.size ();
+
     if (!debug.baseOnly) {
 	const auto magentaCompositeTint = findMagentaCompositeTint (this->m_image, debug.skipEffects);
 	if (magentaCompositeTint.has_value ()) {
@@ -1886,6 +2073,37 @@ void CImage::setup () {
 	));
     }
 
+    if (this->m_hasPuppetMesh && !this->m_passes.empty ()) {
+	this->m_puppetMeshPass = this->m_passes.front ();
+	this->m_puppetMeshLast = this->m_passes.size () == 1;
+
+	// effect masks are laid out over the source texture, so effects run on the flat texture first
+	// and the warped mesh is drawn last, sampling their output
+	const auto& materialPasses = this->getImage ().model->material->passes;
+	const bool hasTrailingPasses = this->m_passes.size () != passCountBeforeTrailingPasses;
+
+	if (this->m_passes.size () > 1 && !hasTrailingPasses && materialPasses.size () == 1
+	    && materialPasses.front ()->constants.empty ()) {
+	    const auto& base = *materialPasses.front ();
+	    const auto& config = *this->m_virtualPassess.emplace_back (std::make_unique<MaterialPass> (MaterialPass {
+		.blending = base.blending,
+		.cullmode = base.cullmode,
+		.depthtest = base.depthtest,
+		.depthwrite = base.depthwrite,
+		.shader = base.shader,
+		.textures = {},
+		.usertextures = {},
+		.combos = base.combos,
+		.constants = {},
+	    }));
+
+	    this->m_puppetMeshPass
+		= new CPass (*this, std::make_shared<FBOProvider> (this), config, std::nullopt, std::nullopt, std::nullopt);
+	    this->m_passes.push_back (this->m_puppetMeshPass);
+	    this->m_puppetMeshLast = true;
+	}
+    }
+
     // if there's more than one pass the blendmode has to be moved from the beginning to the end
     if (this->m_passes.size () > 1) {
 	const auto first = this->m_passes.begin ();
@@ -1920,16 +2138,17 @@ void CImage::setupPasses () {
 	std::shared_ptr<const CFBO> prevDrawTo = drawTo;
 	bool writesToTarget = false;
 	const bool isFirstPass = first;
-	GLuint spacePosition = (isFirstPass)
-	    ? (this->m_hasPuppetMesh ? this->m_puppetSpacePosition : this->getCopySpacePosition ())
-	    : this->getPassSpacePosition ();
+	const bool isMeshPass = this->m_hasPuppetMesh && pass == this->m_puppetMeshPass;
+	GLuint spacePosition = isMeshPass ? this->m_puppetSpacePosition
+	    : isFirstPass                 ? this->getCopySpacePosition ()
+					  : this->getPassSpacePosition ();
 	const glm::mat4* projection
 	    = (isFirstPass) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
 	const glm::mat4* inverseProjection
 	    = (isFirstPass) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
 	first = false;
 
-	if (isFirstPass && this->m_hasPuppetMesh) {
+	if (isMeshPass) {
 	    pass->setBlendingMode (BlendingMode_Translucent);
 	    this->setupPuppetGeometryCallback (pass);
 	}
@@ -2098,13 +2317,15 @@ void CImage::render () {
 #endif /* DEBUG */
 
     auto cur = this->m_passes.begin ();
+    const auto end = this->m_passes.end ();
 
-    for (const auto end = this->m_passes.end (); cur != end; ++cur) {
+    for (; cur != end; ++cur) {
 	if (std::next (cur) == end) {
 	    glColorMask (true, true, true, false);
 	}
 
 	(*cur)->render ();
+
     }
 
     // restore alpha writes - CParticle::render() never resets glColorMask, so leaving this
@@ -2124,7 +2345,11 @@ const float& CImage::getAlpha () const { return this->m_image.alpha->value->getF
 
 const glm::vec3& CImage::getColor () const { return this->m_image.color->value->getVec3 (); }
 
-const glm::vec4& CImage::getColor4 () const { return this->m_image.color->value->getVec4 (); }
+const glm::vec4& CImage::getColor4 () const {
+    // "version" 2 materials take color and alpha together through g_Color4
+    m_color4Cache = glm::vec4 (this->m_image.color->value->getVec3 (), this->m_image.alpha->value->getFloat ());
+    return m_color4Cache;
+}
 
 const glm::vec3& CImage::getCompositeColor () const { return this->m_image.color->value->getVec3 (); }
 
@@ -2312,11 +2537,34 @@ void CImage::updateScreenSpacePosition () {
 	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
     }
 
+    if (transform.meshPivotAngle != 0.0f && this->m_hasPuppetMesh) {
+	const auto& source = !this->m_puppetSkinnedPositions.empty () ? this->m_puppetSkinnedPositions : this->m_puppetRawPositions;
+	glm::vec2 boundsMin (std::numeric_limits<float>::max ());
+	glm::vec2 boundsMax (std::numeric_limits<float>::lowest ());
+	for (size_t i = 0; i + 2 < source.size (); i += 3) {
+	    boundsMin = glm::min (boundsMin, glm::vec2 (source[i], source[i + 1]));
+	    boundsMax = glm::max (boundsMax, glm::vec2 (source[i], source[i + 1]));
+	}
+
+	if (boundsMin.x <= boundsMax.x) {
+	    const glm::vec2 meshCenter = (boundsMin + boundsMax) / 2.0f;
+	    const glm::vec4 pivot (
+		this->m_pos.x + (this->m_size.x / 2.0f + meshCenter.x) * this->m_puppetScale.x,
+		this->m_pos.w + (this->m_size.y / 2.0f - meshCenter.y) * this->m_puppetScale.y, 0.0f, 1.0f
+	    );
+	    const glm::vec3 rotatedPivot = glm::vec3 (rotModel * pivot);
+	    glm::mat4 pivotRot = glm::translate (glm::mat4 (1.0f), rotatedPivot);
+	    pivotRot = glm::rotate (pivotRot, -transform.meshPivotAngle, glm::vec3 (0.0f, 0.0f, 1.0f));
+	    pivotRot = glm::translate (pivotRot, -rotatedPivot);
+	    rotModel = pivotRot * rotModel;
+	}
+    }
+
     glm::mat4 mvp
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt () * rotModel;
 
-    if (this->getScene ().getScene ().camera.parallax.enabled
-	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+    // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
+    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
 	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
 	const glm::vec2 depth = this->getImage ().parallaxDepth->value->getVec2 ();
 	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
@@ -2359,6 +2607,17 @@ glm::vec2 CImage::getSize () const {
 	return this->getImage ().size;
     }
 
+    // compose layers sample the whole scene, but effect masks map over the layer's own size
+    if (this->getImage ().model->passthrough && this->getImage ().size.x > 0.0f
+	&& this->getImage ().size.y > 0.0f) {
+	return this->getImage ().size;
+    }
+
+    // solid layers use a stock white texture, the real footprint is declared by the scene
+    if (this->getImage ().model->solidlayer && this->getImage ().size.x > 0.0f && this->getImage ().size.y > 0.0f) {
+	return this->getImage ().size;
+    }
+
     return { this->m_texture->getRealWidth (), this->m_texture->getRealHeight () };
 }
 

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

@@ -102,6 +102,8 @@ public:
 	glm::vec3 position;
 	float angle;
 	glm::vec2 scale;
+	/** the same attachment point's rotation in the rig's bind pose */
+	float restAngle;
     };
 
     /**
@@ -119,6 +121,8 @@ protected:
 	glm::vec3 origin;
 	glm::vec3 scale;
 	float angle;
+	/** part of `angle` that should pivot around the puppet mesh's own center instead of the object origin */
+	float meshPivotAngle = 0.0f;
     };
 
     [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object) const;
@@ -158,6 +162,9 @@ private:
     GLuint m_puppetIndices = GL_NONE;
     GLsizei m_puppetIndexCount = 0;
     bool m_hasPuppetMesh = false;
+    // the pass that draws the warped mesh, and whether it is the last one (straight into the scene FBO)
+    Effects::CPass* m_puppetMeshPass = nullptr;
+    bool m_puppetMeshLast = false;
     mutable bool m_puppetDrawDiagnosticLogged = false;
     mutable bool m_puppetDrawErrorChecked = false;
     bool m_puppetPositionDiagnosticLogged = false;
@@ -183,6 +190,11 @@ private:
     std::vector<PuppetActiveAnimation> m_puppetActiveAnimations = {};
     std::vector<GLfloat> m_puppetSkinnedPositions = {};
 
+    // TEMP-DIAG: CPU-side puppet texcoords/indices, only used by the overlap check
+    std::vector<GLfloat> m_puppetTexCoordData = {};
+    std::vector<GLushort> m_puppetIndicesData = {};
+    bool m_puppetOverlapDiagLogged = false;
+
     std::vector<PuppetAttachmentPoint> m_puppetAttachmentPoints = {};
     /** Per-bone current animated world transform, in the puppet's own local mesh space; starts out equal
      *  to the bind pose and is refreshed every frame by updatePuppetSkinning while animation is active */
@@ -204,6 +216,7 @@ private:
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
 
     const Image& m_image;
+    mutable glm::vec4 m_color4Cache {};
 
     std::vector<Effects::CPass*> m_passes = {};
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};

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

@@ -193,7 +193,7 @@ void CParticle::render () {
     float dt = currentTime - static_cast<float> (m_time);
     m_time = currentTime;
 
-    if (dt > 0.0f) {
+    if (dt > 0.0f && this->isPlaying ()) {
 	// Cap dt to prevent simulation instability across different FPS
 	dt = std::min (dt, 0.1f);
 	update (dt);
@@ -1775,8 +1775,8 @@ void CParticle::updateMatrices () {
 }
 
 void CParticle::applyParallaxToModelMatrix () {
-    if (!getScene ().getScene ().camera.parallax.enabled
-	|| getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+    // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
+    if (!getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
 	return;
     }
 

+ 1 - 1
src/WallpaperEngine/Render/Objects/CRenderable.cpp

@@ -20,7 +20,7 @@ void CRenderable::detectTexture () {
 	if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 	    this->m_texture = this->getScene ().findFBO (textureName);
 	} else {
-	    this->m_texture = this->getContext ().resolveTexture (textureName);
+	    this->m_texture = this->getContext ().resolveTexture (textureName, this->getScene ().getScene ().project);
 	}
     }
 }

+ 13 - 2
src/WallpaperEngine/Render/Objects/CSound.cpp

@@ -8,7 +8,8 @@
 
 using namespace WallpaperEngine::Render::Objects;
 
-CSound::CSound (Wallpapers::CScene& scene, const Sound& sound) : CObject (scene, sound), m_sound (sound) {
+CSound::CSound (Wallpapers::CScene& scene, const Sound& sound) :
+    CObject (scene, sound), m_sound (sound), m_playing (scene.getSoundPlayRequest (sound.id).value_or (!sound.startsilent.value_or (false))) {
     if (this->getContext ().getApp ().getContext ().settings.audio.enabled) {
 	this->load ();
     }
@@ -36,6 +37,16 @@ void CSound::load () {
 
 void CSound::render () { this->applyEffectiveVolume (); }
 
+void CSound::play () {
+    this->m_playing = true;
+    this->applyEffectiveVolume ();
+}
+
+void CSound::stop () {
+    this->m_playing = false;
+    this->applyEffectiveVolume ();
+}
+
 void CSound::setVolumeOverride (std::optional<int> volume) {
     this->m_screenVolumeOverride = volume;
     this->applyEffectiveVolume ();
@@ -53,7 +64,7 @@ void CSound::applyEffectiveVolume () {
     const float fraction = this->m_sound.volume && this->m_sound.volume->value
 	? std::clamp (this->m_sound.volume->value->getFloat (), 0.0f, 1.0f)
 	: 1.0f;
-    const int effective = static_cast<int> (static_cast<float> (base) * fraction);
+    const int effective = this->m_playing ? static_cast<int> (static_cast<float> (base) * fraction) : 0;
 
     for (const auto& entry : this->m_audioStreams) {
 	this->getScene ().getAudioContext ().setStreamVolume (entry.first, effective);

+ 6 - 0
src/WallpaperEngine/Render/Objects/CSound.h

@@ -24,6 +24,11 @@ public:
     /** Overrides the volume (0-128) this sound's streams mix at instead of the global volume; nullopt = global */
     void setVolumeOverride (std::optional<int> volume);
 
+    /** Script-facing playback control (thisScene.getLayer(<sound name>).play()/stop()/pause()) */
+    void play ();
+    void stop ();
+    [[nodiscard]] bool isPlaying () const { return this->m_playing; }
+
 protected:
     void load ();
 
@@ -35,5 +40,6 @@ private:
     const Sound& m_sound;
     /** Screen-level mute/ambient-volume policy from CScene::setAudioPolicy; nullopt = no override */
     std::optional<int> m_screenVolumeOverride;
+    bool m_playing = true;
 };
 } // namespace WallpaperEngine::Render::Objects

+ 396 - 117
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -1,6 +1,8 @@
 #include "CText.h"
 
 #include <algorithm>
+#include <cstdio>
+#include <unordered_map>
 #include <filesystem>
 #include <sstream>
 #include <vector>
@@ -92,6 +94,47 @@ const std::vector<std::string> kFontCandidates = {
     "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
 };
 
+// WE's "systemfont_*" names are Windows fonts, ask fontconfig for the closest installed match
+std::string fontconfigMatch (const std::string& font) {
+    static const std::vector<std::pair<std::string, std::string>> families = {
+	{ "systemfont_arial", "Arial,Liberation Sans,Arimo" },
+	{ "systemfont_calibri", "Calibri,Carlito" },
+	{ "systemfont_cambria", "Cambria,Caladea" },
+	{ "systemfont_comicsans", "Comic Sans MS,Comic Neue,Comic Relief" },
+	{ "systemfont_consolas", "Consolas,Inconsolata,DejaVu Sans Mono" },
+	{ "systemfont_sansserif", "sans-serif" },
+	{ "systemfont_segoe", "Segoe UI,Noto Sans,Open Sans" },
+	{ "systemfont_verdana", "Verdana,DejaVu Sans" },
+    };
+
+    std::string family = "sans-serif";
+
+    for (const auto& [name, list] : families) {
+	if (font == name) {
+	    family = list;
+	    break;
+	}
+    }
+
+    const std::string command = "fc-match -f '%{file}' '" + family + "' 2>/dev/null";
+    FILE* pipe = popen (command.c_str (), "r");
+
+    if (pipe == nullptr) {
+	return {};
+    }
+
+    std::string path;
+    char buffer[512];
+
+    while (fgets (buffer, sizeof (buffer), pipe) != nullptr) {
+	path += buffer;
+    }
+
+    pclose (pipe);
+
+    return !path.empty () && std::filesystem::exists (path) ? path : std::string ();
+}
+
 // Wraps the FreeType-rasterized glyph coverage bitmap (single R8 channel) as a
 // TextureProvider so it can be fed into the normal CRenderable/CPass pipeline.
 class TextGlyphTexture final : public WallpaperEngine::Render::TextureProvider {
@@ -227,6 +270,7 @@ CText::CText (Wallpapers::CScene& scene, const Text& text) :
     this->registerProperty ("pointSize", *text.pointSize->value);
     this->registerProperty ("text", *text.text->value);
     this->registerProperty ("parallaxDepth", *text.parallaxDepth->value);
+    this->registerEffectConstants (text.effects);
 }
 
 CText::~CText () {
@@ -240,6 +284,9 @@ CText::~CText () {
     if (m_copySpacePosition != 0) {
 	glDeleteBuffers (1, &m_copySpacePosition);
     }
+    if (m_passSpacePosition != 0) {
+	glDeleteBuffers (1, &m_passSpacePosition);
+    }
     if (m_sceneSpacePosition != 0) {
 	glDeleteBuffers (1, &m_sceneSpacePosition);
     }
@@ -349,11 +396,14 @@ bool CText::loadEmbeddedFont () {
 }
 
 bool CText::loadSystemFont () {
-    std::string fontPath;
-    for (const auto& candidate : kFontCandidates) {
-	if (std::filesystem::exists (candidate)) {
-	    fontPath = candidate;
-	    break;
+    std::string fontPath = fontconfigMatch (m_text.font);
+
+    if (fontPath.empty ()) {
+	for (const auto& candidate : kFontCandidates) {
+	    if (std::filesystem::exists (candidate)) {
+		fontPath = candidate;
+		break;
+	    }
 	}
     }
     if (fontPath.empty ()) {
@@ -368,23 +418,9 @@ bool CText::loadSystemFont () {
 }
 
 unsigned int CText::computeEffectivePixelSize () const {
-    // WE text objects often come with scale ~0.09 that, combined with a modest pointsize, would
-    // rasterize glyphs to ~2px on screen (invisible). Rasterize at higher resolution so that
-    // after the model scale is applied in render() the on-screen size matches the intended
-    // pointsize.
-    //
-    // For scale >= 1, measurements against real Wallpaper Engine show the final glyph size also
-    // needs an extra factor of scale beyond the model-matrix multiply in render() - final size
-    // scales with scale^2, not scale^1.
-    const glm::vec3 initialScale = m_text.scale->value->getVec3 ();
-    const float avgScale = (initialScale.x + initialScale.y) * 0.5f;
-    float compensate = 1.0f;
-    if (avgScale > 0.0f && avgScale < 1.0f) {
-	compensate = std::min (1.0f / avgScale, 32.0f);
-    } else if (avgScale >= 1.0f) {
-	compensate = std::min (avgScale, 32.0f);
-    }
-    return std::max<unsigned int> (1u, static_cast<unsigned int> (m_text.pointSize->value->getFloat () * compensate));
+    // WE rasterizes glyphs at pointsize * 300 / 72 pixels (wallpaper64.exe), the object's scale only applies to the quad
+    const float pointSize = std::clamp (m_text.pointSize->value->getFloat (), 1.0f, 256.0f);
+    return std::max<unsigned int> (1u, static_cast<unsigned int> (std::lround (pointSize * 300.0f / 72.0f)));
 }
 
 void CText::initScriptLayer () {
@@ -394,6 +430,12 @@ void CText::initScriptLayer () {
 	return;
     }
 
+    // a script already running as a regular property module drives the value itself
+    if (this->getScene ().getScriptEngine ().hasScript (*m_text.text->value)) {
+	m_textFromProperty = true;
+	return;
+    }
+
     m_layerHandle = this->getScene ().getScriptEngine ().createLayerScript (
 	*script, m_text.text->value->getProperties (), m_text.text->value->getString ()
     );
@@ -404,109 +446,251 @@ void CText::initScriptLayer () {
 }
 
 void CText::rebuildTextureFrom (const std::string& text) {
-    // Two-pass rasterization per line: first measure each line's bounding box, then
-    // rasterize every glyph into a single grayscale bitmap. Lines split on '\n' are
-    // stacked top-to-bottom and each centered horizontally within the overall width.
+    // like wallpaper64.exe, the layout box is made of whole font lines rather than the glyphs' ink
     FT_GlyphSlot slot = m_ftFace->glyph;
+    const auto& faceMetrics = m_ftFace->size->metrics;
+    const int ascender = std::max (1, static_cast<int> ((faceMetrics.ascender + 63) >> 6));
+    const int descender = std::max (0, static_cast<int> ((-faceMetrics.descender + 63) >> 6));
+    const int lineHeight = std::max (ascender + descender, static_cast<int> ((faceMetrics.height + 63) >> 6));
+
+    std::unordered_map<char32_t, int> advances;
+    const auto advanceOf = [&] (char32_t c) {
+	const auto cached = advances.find (c);
+
+	if (cached != advances.end ()) {
+	    return cached->second;
+	}
+
+	int advance = 0;
+
+	if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_DEFAULT) == 0) {
+	    advance = static_cast<int> (slot->advance.x >> 6);
+	}
+
+	advances.emplace (c, advance);
+	return advance;
+    };
+    const auto widthOf = [&] (const std::vector<char32_t>& codepoints) {
+	int width = 0;
+
+	for (const char32_t c : codepoints) {
+	    width += advanceOf (c);
+	}
+
+	return width;
+    };
+    const auto trimTrailingSpaces = [] (std::vector<char32_t>& codepoints) {
+	while (!codepoints.empty () && (codepoints.back () == U' ' || codepoints.back () == U'\t')) {
+	    codepoints.pop_back ();
+	}
+    };
+
+    const bool limitWidth = m_text.limitWidth->value->getBool ();
+    const int maxLineWidth = std::max (1, static_cast<int> (m_text.maxWidth->value->getFloat ()));
+
+    std::vector<std::vector<char32_t>> lines;
+    size_t paragraphStart = 0;
 
-    std::vector<std::string> lines;
-    size_t lineStart = 0;
     while (true) {
-	const size_t pos = text.find ('\n', lineStart);
+	const size_t pos = text.find ('\n', paragraphStart);
+	std::string paragraph = text.substr (paragraphStart, pos == std::string::npos ? pos : pos - paragraphStart);
+
+	if (!paragraph.empty () && paragraph.back () == '\r') {
+	    paragraph.pop_back ();
+	}
+
+	const auto codepoints = decodeUtf8 (paragraph);
+
+	if (!limitWidth) {
+	    lines.push_back (codepoints);
+	} else {
+	    std::vector<char32_t> current;
+	    int currentWidth = 0;
+	    const auto flush = [&] () {
+		trimTrailingSpaces (current);
+		lines.push_back (std::move (current));
+		current.clear ();
+		currentWidth = 0;
+	    };
+
+	    for (size_t i = 0; i < codepoints.size ();) {
+		const bool space = codepoints[i] == U' ';
+		size_t j = i;
+
+		while (j < codepoints.size () && (codepoints[j] == U' ') == space) {
+		    j++;
+		}
+
+		const std::vector<char32_t> token (codepoints.begin () + static_cast<long> (i), codepoints.begin () + static_cast<long> (j));
+		const int tokenWidth = widthOf (token);
+		i = j;
+
+		if (space) {
+		    // spaces at the start of a wrapped line are dropped
+		    if (!current.empty () || lines.empty ()) {
+			current.insert (current.end (), token.begin (), token.end ());
+			currentWidth += tokenWidth;
+		    }
+		    continue;
+		}
+
+		if (currentWidth + tokenWidth <= maxLineWidth) {
+		    current.insert (current.end (), token.begin (), token.end ());
+		    currentWidth += tokenWidth;
+		    continue;
+		}
+
+		if (!current.empty ()) {
+		    flush ();
+		}
+
+		if (tokenWidth <= maxLineWidth) {
+		    current = token;
+		    currentWidth = tokenWidth;
+		    continue;
+		}
+
+		// a single word wider than the box gets broken between characters
+		for (const char32_t c : token) {
+		    const int advance = advanceOf (c);
+
+		    if (currentWidth + advance > maxLineWidth && !current.empty ()) {
+			flush ();
+		    }
+
+		    current.push_back (c);
+		    currentWidth += advance;
+		}
+	    }
+
+	    flush ();
+	}
+
 	if (pos == std::string::npos) {
-	    lines.push_back (text.substr (lineStart));
 	    break;
 	}
-	lines.push_back (text.substr (lineStart, pos - lineStart));
-	lineStart = pos + 1;
-    }
 
-    std::vector<std::vector<char32_t>> lineCodepoints (lines.size ());
-    for (size_t i = 0; i < lines.size (); ++i) {
-	lineCodepoints[i] = decodeUtf8 (lines[i]);
+	paragraphStart = pos + 1;
     }
 
-    struct LineMetrics {
-	int width = 0;
-	int ascent = 0;
-	int descent = 0;
-    };
+    if (m_text.limitRows->value->getBool ()) {
+	const auto maxRows = static_cast<size_t> (std::max (1, static_cast<int> (std::lround (m_text.maxRows->value->getFloat ()))));
 
-    std::vector<LineMetrics> lineMetrics (lines.size ());
-    int maxWidth = 0;
-    int maxLineHeight = 0;
+	if (lines.size () > maxRows) {
+	    lines.resize (maxRows);
 
-    for (size_t i = 0; i < lines.size (); ++i) {
-	int penX = 0;
-	int maxAscent = 0;
-	int maxDescent = 0;
+	    if (m_text.limitUseEllipsis->value->getBool ()) {
+		auto& last = lines.back ();
+		trimTrailingSpaces (last);
 
-	for (char32_t c : lineCodepoints[i]) {
-	    if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
-		continue;
+		std::vector<char32_t> ellipsis = { U'\u2026' };
+
+		if (FT_Get_Char_Index (m_ftFace, U'\u2026') == 0) {
+		    ellipsis = { U'.', U'.', U'.' };
+		}
+
+		if (last.size () < ellipsis.size () || !std::equal (ellipsis.begin (), ellipsis.end (), last.end () - static_cast<long> (ellipsis.size ()))) {
+		    last.insert (last.end (), ellipsis.begin (), ellipsis.end ());
+		}
+
+		while (limitWidth && widthOf (last) > maxLineWidth && last.size () > ellipsis.size () + 1) {
+		    last.erase (last.end () - static_cast<long> (ellipsis.size ()) - 1);
+		}
 	    }
-	    penX += slot->advance.x >> 6;
-	    maxAscent = std::max (maxAscent, slot->bitmap_top);
-	    maxDescent = std::max (maxDescent, static_cast<int> (slot->bitmap.rows) - slot->bitmap_top);
 	}
+    }
+
+    int maxWidth = 0;
+    std::vector<int> lineWidths (lines.size ());
 
-	lineMetrics[i] = { std::max (0, penX), maxAscent, maxDescent };
-	maxWidth = std::max (maxWidth, lineMetrics[i].width);
-	maxLineHeight = std::max (maxLineHeight, maxAscent + maxDescent);
+    for (size_t i = 0; i < lines.size (); ++i) {
+	lineWidths[i] = widthOf (lines[i]);
+	maxWidth = std::max (maxWidth, lineWidths[i]);
     }
 
-    // Uniform line pitch (with a little breathing room) based on the tallest line.
-    const int linePitch = std::max (1, static_cast<int> (static_cast<float> (maxLineHeight) * 1.2f));
     const int width = std::max (1, maxWidth);
-    const int height
-	= std::max (1, linePitch * static_cast<int> (lines.size ()) - (linePitch - maxLineHeight));
-    std::vector<uint8_t> pixels (static_cast<size_t> (width) * height, 0);
+    const int height = ascender + descender + static_cast<int> (lines.size () - 1) * lineHeight;
 
-    for (size_t i = 0; i < lines.size (); ++i) {
-	int penX = (width - lineMetrics[i].width) / 2;
-	const int lineTop = static_cast<int> (i) * linePitch;
-	const int maxAscent = lineMetrics[i].ascent;
+    const auto forEachGlyph = [&] (const auto& visit) {
+	for (size_t i = 0; i < lines.size (); ++i) {
+	    int penX = 0;
 
-	for (char32_t c : lineCodepoints[i]) {
-	    if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
-		continue;
+	    if (m_text.alignment == "center") {
+		penX = (width - lineWidths[i]) / 2;
+	    } else if (m_text.alignment == "right") {
+		penX = width - lineWidths[i];
 	    }
 
-	    const auto& bmp = slot->bitmap;
-	    const int originX = penX + slot->bitmap_left;
-	    const int originY = lineTop + maxAscent - slot->bitmap_top;
+	    const int baseline = ascender + static_cast<int> (i) * lineHeight;
 
-	    for (unsigned int row = 0; row < bmp.rows; ++row) {
-		for (unsigned int col = 0; col < bmp.width; ++col) {
-		    const int dstX = originX + static_cast<int> (col);
-		    const int dstY = originY + static_cast<int> (row);
-		    if (dstX < 0 || dstX >= width || dstY < 0 || dstY >= height) {
-			continue;
-		    }
-		    pixels[static_cast<size_t> (dstY) * width + dstX] = bmp.buffer[row * bmp.pitch + col];
+	    for (const char32_t c : lines[i]) {
+		if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
+		    continue;
 		}
+
+		visit (slot->bitmap, penX + slot->bitmap_left, baseline - slot->bitmap_top);
+		penX += slot->advance.x >> 6;
 	    }
+	}
+    };
+
+    // grow the texture for overhanging glyphs and leave room for effects that spread past them
+    const int margin = std::max (16, static_cast<int> (m_lastPixelSize) / 6);
+    int padLeft = margin;
+    int padRight = margin;
+    int padTop = margin;
+    int padBottom = margin;
 
-	    penX += slot->advance.x >> 6;
+    forEachGlyph ([&] (const FT_Bitmap& bmp, int originX, int originY) {
+	if (bmp.width == 0 || bmp.rows == 0) {
+	    return;
 	}
-    }
+
+	padLeft = std::max (padLeft, margin - originX);
+	padRight = std::max (padRight, margin + originX + static_cast<int> (bmp.width) - width);
+	padTop = std::max (padTop, margin - originY);
+	padBottom = std::max (padBottom, margin + originY + static_cast<int> (bmp.rows) - height);
+    });
+
+    const int textureWidth = width + padLeft + padRight;
+    const int textureHeight = height + padTop + padBottom;
+    std::vector<uint8_t> pixels (static_cast<size_t> (textureWidth) * textureHeight, 0);
+
+    forEachGlyph ([&] (const FT_Bitmap& bmp, int originX, int originY) {
+	for (unsigned int row = 0; row < bmp.rows; ++row) {
+	    for (unsigned int col = 0; col < bmp.width; ++col) {
+		const int dstX = padLeft + originX + static_cast<int> (col);
+		const int dstY = padTop + originY + static_cast<int> (row);
+
+		// glyphs may overlap by a pixel (kerning-less advances): keep the stronger coverage
+		auto& dst = pixels[static_cast<size_t> (dstY) * textureWidth + dstX];
+		dst = std::max (dst, bmp.buffer[row * bmp.pitch + col]);
+	    }
+	}
+    });
+
+    // where the layout box sits relative to the texture's center, in texture pixels (y down)
+    m_boxSize = { static_cast<float> (width), static_cast<float> (height) };
+    m_boxShift = { static_cast<float> (padLeft - padRight) * 0.5f, static_cast<float> (padTop - padBottom) * 0.5f };
+
+    m_descender = descender;
 
     const glm::ivec2 previousSize = m_textureSize;
 
     // FreeType bitmaps store row 0 as the glyph's TOP row; upload as-is and flip
     // when building the quad's V coordinates instead (see uploadQuadVertices).
     std::vector<uint8_t> flipped (pixels.size ());
-    for (int row = 0; row < height; ++row) {
+    for (int row = 0; row < textureHeight; ++row) {
 	std::copy_n (
-	    pixels.begin () + static_cast<long> (row) * width, width,
-	    flipped.begin () + static_cast<long> (height - 1 - row) * width
+	    pixels.begin () + static_cast<long> (row) * textureWidth, textureWidth,
+	    flipped.begin () + static_cast<long> (textureHeight - 1 - row) * textureWidth
 	);
     }
 
-    static_cast<TextGlyphTexture*> (m_glyphTexture.get ())->upload (width, height, flipped.data ());
+    static_cast<TextGlyphTexture*> (m_glyphTexture.get ())->upload (textureWidth, textureHeight, flipped.data ());
 
-    m_textureSize = { width, height };
-    m_quadSize = { static_cast<float> (width), static_cast<float> (height) };
+    m_textureSize = { textureWidth, textureHeight };
+    m_quadSize = { static_cast<float> (textureWidth), static_cast<float> (textureHeight) };
     m_lastRenderedText = text;
 
     uploadQuadVertices ();
@@ -530,6 +714,9 @@ void CText::uploadQuadVertices () {
     const GLfloat copySpacePosition[]
 	= { 0.0f, h, 0.0f, 0.0f, 0.0f, 0.0f, w, h, 0.0f, w, h, 0.0f, 0.0f, 0.0f, 0.0f, w, 0.0f, 0.0f };
 
+    const GLfloat passSpacePosition[]
+	= { -1.0f, 1.0f, 0.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, 1.0f, -1.0f, 0.0f };
+
     // "Scene space": centered quad used for the final composite pass, positioned via
     // the scene MVP (translate + scale) computed in render().
     const GLfloat sceneSpacePosition[]
@@ -543,6 +730,12 @@ void CText::uploadQuadVertices () {
     glBindBuffer (GL_ARRAY_BUFFER, m_copySpacePosition);
     glBufferData (GL_ARRAY_BUFFER, sizeof (copySpacePosition), copySpacePosition, GL_STATIC_DRAW);
 
+    if (m_passSpacePosition == 0) {
+	glGenBuffers (1, &m_passSpacePosition);
+    }
+    glBindBuffer (GL_ARRAY_BUFFER, m_passSpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (passSpacePosition), passSpacePosition, GL_STATIC_DRAW);
+
     if (m_sceneSpacePosition == 0) {
 	glGenBuffers (1, &m_sceneSpacePosition);
     }
@@ -575,9 +768,9 @@ void CText::buildPasses () {
     nameB << "_rt_textComposite_" << this->getId () << "_b";
 
     this->m_currentMainFBO = this->m_mainFBO
-	= this->create (nameA.str (), TextureFormat_ARGB8888, TextureFlags_NoFlags, 1.0f, fboSize, fboSize);
+	= 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_NoFlags, 1.0f, fboSize, fboSize);
+	= this->create (nameB.str (), TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0f, fboSize, fboSize);
 
     // base pass: tint the glyph coverage texture and render it into the first FBO
     for (const auto& pass : fontMaterial ().passes) {
@@ -605,43 +798,77 @@ void CText::buildPasses () {
 
 	    const auto fboProvider = std::make_shared<FBOProvider> (this);
 	    for (const auto& fbo : effect->effect->fbos) {
-		fboProvider->create (*fbo, TextureFlags_NoFlags, fboSize);
+		fboProvider->create (*fbo, TextureFlags_ClampUVs, fboSize);
 	    }
 
 	    auto curOverride = effect->passOverrides.begin ();
 	    const auto endOverride = effect->passOverrides.end ();
 
+	    // same target/previous bookkeeping as CImage::setupPasses
+	    bool inTargetSequence = false;
+	    std::shared_ptr<const TextureProvider> sequenceInput = nullptr;
+
 	    for (const auto& effectPass : effect->effect->passes) {
 		if (!effectPass->material.has_value ()) {
 		    // command-only passes (e.g. plain FBO copies) aren't supported for text
 		    continue;
 		}
 
-		std::shared_ptr<const CFBO> drawTo = this->m_currentSubFBO;
-
 		for (auto& matPass : effectPass->material.value ()->passes) {
 		    const auto override = curOverride != endOverride
 			? **curOverride
 			: std::optional<std::reference_wrapper<const ImageEffectPassOverride>> (std::nullopt);
+		    const auto target = effectPass->target.has_value ()
+			? *effectPass->target
+			: std::optional<std::reference_wrapper<std::string>> (std::nullopt);
+
+		    auto* cpass = new CPass (*this, fboProvider, *matPass, override, effectPass->binds, target);
+		    std::shared_ptr<const CFBO> drawTo = this->m_currentSubFBO;
+		    bool writesToTarget = false;
+
+		    if (target.has_value ()) {
+			std::shared_ptr<const CFBO> resolved = fboProvider->find (target->get ());
+
+			if (resolved == nullptr) {
+			    resolved = this->getScene ().findFBO (target->get ());
+			}
+
+			if (resolved != nullptr) {
+			    if (!inTargetSequence) {
+				sequenceInput = asInput;
+				inTargetSequence = true;
+			    }
+
+			    drawTo = resolved;
+			    writesToTarget = true;
+			} else {
+			    sLog.error ("Text pass target FBO '", target->get (), "' could not be resolved for ", m_text.name);
+			}
+		    }
 
-		    auto* cpass = new CPass (*this, fboProvider, *matPass, override, effectPass->binds, std::nullopt);
 		    cpass->setDestination (drawTo);
 		    cpass->setInput (asInput);
-		    cpass->setPosition (m_copySpacePosition);
+		    cpass->setPreviousInput (inTargetSequence ? sequenceInput : nullptr);
+		    cpass->setPosition (m_passSpacePosition);
 		    cpass->setTexCoord (m_texcoordCopy);
 		    cpass->setModelMatrix (&m_modelMatrix);
 		    cpass->setViewProjectionMatrix (&m_viewProjectionMatrix);
-		    cpass->setModelViewProjectionMatrix (&m_modelViewProjectionCopy);
-		    cpass->setModelViewProjectionMatrixInverse (&m_modelViewProjectionCopyInverse);
+		    cpass->setModelViewProjectionMatrix (&m_modelViewProjectionPass);
+		    cpass->setModelViewProjectionMatrixInverse (&m_modelViewProjectionPass);
 		    m_passes.push_back (cpass);
+
+		    asInput = drawTo;
+
+		    if (!writesToTarget) {
+			std::swap (this->m_currentMainFBO, this->m_currentSubFBO);
+			inTargetSequence = false;
+			sequenceInput = nullptr;
+		    }
 		}
 
 		if (curOverride != endOverride) {
 		    ++curOverride;
 		}
-
-		asInput = drawTo;
-		std::swap (this->m_currentMainFBO, this->m_currentSubFBO);
 	    }
 	}
     }
@@ -681,6 +908,9 @@ void CText::render () {
 	);
 	const std::string current = se.layerText (m_layerHandle);
 	renderedText = current.empty () ? std::string (" ") : current;
+    } else if (m_textFromProperty) {
+	const std::string current = m_text.text->value->getString ();
+	renderedText = current.empty () ? std::string (" ") : current;
     }
 
     const unsigned int pixelSize = computeEffectivePixelSize ();
@@ -692,33 +922,82 @@ void CText::render () {
 	rebuildTextureFrom (renderedText);
     }
 
-    const glm::vec3 scale = m_text.scale->value->getVec3 ();
-    const glm::vec3 origin = m_text.origin->value->getVec3 ();
+    glm::vec3 scale = m_text.scale->value->getVec3 ();
+    glm::vec3 origin = m_text.origin->value->getVec3 ();
+
+    // 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);
+	};
+
+	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 (node.is<Image> ()) {
+		nodeScale = node.as<Image> ()->scale->value->getVec3 ();
+		nodeAngle = node.as<Image> ()->angles->value->getVec3 ().z;
+	    }
+
+	    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;
+	}
+
+	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;
+    }
 
-    // origin is the box's center (like every other object in the scene); size/padding/align
-    // place the glyph quad within that box, relative to its center. Half-extents use the
-    // post-scale size so edges land on the padded box boundary regardless of "scale".
-    const float scaledHalfWidth = m_quadSize.x * 0.5f * scale.x;
-    const float scaledHalfHeight = m_quadSize.y * 0.5f * scale.y;
+    // the glyph bbox is centered on the origin then shifted by the alignment anchor (wallpaper64.exe), json "size" is not used
+    const float scaledHalfWidth = m_boxSize.x * 0.5f * scale.x;
+    const float scaledHalfHeight = m_boxSize.y * 0.5f * scale.y;
 
-    float offsetX;
+    float offsetX = 0.0f;
     if (m_text.alignment == "left") {
-	offsetX = -m_text.size.x * 0.5f + m_text.padding.x + scaledHalfWidth;
+	offsetX = scaledHalfWidth;
     } else if (m_text.alignment == "right") {
-	offsetX = m_text.size.x * 0.5f - m_text.padding.x - scaledHalfWidth;
-    } else {
-	offsetX = 0.0f;
+	offsetX = -scaledHalfWidth;
     }
 
-    float offsetY;
+    // offsetY is added to origin.y, which grows towards the top of the screen
+    float offsetY = 0.0f;
     if (m_text.verticalalign == "top") {
-	offsetY = -m_text.size.y * 0.5f + m_text.padding.y + scaledHalfHeight;
+	offsetY = -scaledHalfHeight;
     } else if (m_text.verticalalign == "bottom") {
-	offsetY = m_text.size.y * 0.5f - m_text.padding.y - scaledHalfHeight;
+	offsetY = scaledHalfHeight;
     } else {
-	offsetY = 0.0f;
+	// "center" is moved down by half the descender, matching WE
+	offsetY = -static_cast<float> (m_descender) * 0.5f * scale.y;
     }
 
+    // the texture center is off the box center by the overhang padding, move the quad the opposite way
+    offsetX -= m_boxShift.x * scale.x;
+    offsetY += m_boxShift.y * scale.y;
+
     // 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.
     const float scene_w = getScene ().getCamera ().getWidth ();
@@ -729,8 +1008,8 @@ void CText::render () {
     // 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 };
-    if (this->getScene ().getScene ().camera.parallax.enabled
-	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+    // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
+    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
 	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
 	const glm::vec2 depth = m_text.parallaxDepth->value->getVec2 ();
 	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();

+ 10 - 3
src/WallpaperEngine/Render/Objects/CText.h

@@ -80,10 +80,10 @@ private:
 
     std::shared_ptr<TextureProvider> m_glyphTexture;
 
-    // Effect passes run FBO -> FBO at a fixed size (m_quadSize), so the same "copy space" quad
-    // is reused for the base pass and every effect pass; only the final composite pass (drawing
-    // to the actual screen) needs its own scene-positioned quad/matrix.
+    // Effect passes run FBO -> FBO at a fixed size (m_quadSize) with an identity matrix like CImage,
+    // only the final composite pass needs its own scene-positioned quad/matrix.
     GLuint m_copySpacePosition = 0;
+    GLuint m_passSpacePosition = 0;
     GLuint m_sceneSpacePosition = 0;
     GLuint m_texcoordCopy = 0;
 
@@ -94,6 +94,7 @@ private:
     std::shared_ptr<const CFBO> m_currentMainFBO = nullptr;
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
 
+    glm::mat4 m_modelViewProjectionPass = glm::mat4 (1.0f);
     glm::mat4 m_modelViewProjectionCopy = {};
     glm::mat4 m_modelViewProjectionCopyInverse = {};
     glm::mat4 m_modelViewProjectionScreen = {};
@@ -101,10 +102,16 @@ private:
     glm::mat4 m_modelMatrix = {};
     glm::mat4 m_viewProjectionMatrix = {};
 
+    bool m_textFromProperty = false;
     mutable glm::vec4 m_color4Cache = { 1.0f, 1.0f, 1.0f, 1.0f };
 
     glm::ivec2 m_textureSize = { 0, 0 };
     glm::vec2 m_quadSize = { 0.0f, 0.0f };
+    int m_descender = 0;
+    /** the layout box (what alignment uses); the texture can be larger when glyphs overhang it */
+    glm::vec2 m_boxSize = { 0.0f, 0.0f };
+    /** layout box center minus texture center, in texture pixels (y down) */
+    glm::vec2 m_boxShift = { 0.0f, 0.0f };
 
     bool m_valid = false;
     bool m_initialized = false;

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

@@ -1,4 +1,7 @@
 #include "CPass.h"
+#include <algorithm>
+#include <cctype>
+#include <cmath>
 #include <sstream>
 #include <utility>
 
@@ -136,6 +139,10 @@ CPass::~CPass () {
 	delete attrib;
     }
 
+    // text layers rebuild their passes on every glyph texture resize, so this can't be left to leak
+    delete this->m_shader;
+    this->m_shader = nullptr;
+
     glDeleteVertexArrays (1, &m_vao);
     this->m_vao = GL_NONE;
 
@@ -230,22 +237,28 @@ void CPass::setupRenderFramebuffer () const {
 
     glViewport (0, 0, this->m_drawTo->getRealWidth (), this->m_drawTo->getRealHeight ());
 
+    // the alpha source factor must be GL_ONE, GL_SRC_ALPHA squares every blended pass's alpha and compounds through chained effects
     switch (this->getBlendingMode ()) {
 	case BlendingMode_Translucent:
 	    glEnable (GL_BLEND);
-	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
 	    break;
 	case BlendingMode_Additive:
 	    glEnable (GL_BLEND);
-	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE, GL_SRC_ALPHA, GL_ONE);
+	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE, GL_ONE, GL_ONE);
 	    break;
 	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.
-	    glEnable (GL_BLEND);
-	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+	    // 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);
@@ -393,8 +406,13 @@ void CPass::bindTextureUnit (int index, const std::shared_ptr<const TextureProvi
 }
 
 void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const {
+    static int diagCallCount = 0;
+    const bool diagTarget
+	= this->m_renderable.getId () == 21 && this->m_pass.shader == "effects/shake" && diagCallCount++ < 6;
     for (auto [index, chain] : this->m_textures) {
 	auto expectedTexture = chain->texture;
+	const auto* requestedChainHead = chain->texture.get ();
+	int chainSteps = 0;
 
 	do {
 	    if (expectedTexture == nullptr) {
@@ -413,14 +431,27 @@ void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const
 
 	    chain = chain->next;
 	    expectedTexture = chain == nullptr ? nullptr : chain->texture;
+	    chainSteps++;
 	} while (chain != nullptr);
 
 	if (expectedTexture == nullptr && this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
 	    expectedTexture = this->m_previousInput;
+	    chainSteps = -1;
 	}
 
 	if (expectedTexture == nullptr) {
 	    expectedTexture = this->m_input;
+	    chainSteps = -2;
+	}
+
+	if (diagTarget) {
+	    sLog.out (
+		"TEMP-DIAG texture-binding-fix bind slot=", index, " requestedTexturePtr=", (const void*) requestedChainHead,
+		" requestedReady=", (requestedChainHead != nullptr && requestedChainHead->isReady ()),
+		" finalTexturePtr=", (const void*) expectedTexture.get (), " matchesRequested=",
+		(expectedTexture.get () == requestedChainHead), " fellBackVia=", chainSteps, " (0=direct hit, >0=chain fallback, -1=previousInput, -2=input)",
+		" finalTextureID=", (expectedTexture != nullptr ? expectedTexture->getTextureID (index == 0 ? currentTexture : 0) : 0)
+	    );
 	}
 
 	this->bindTextureUnit (index, expectedTexture, index == 0 ? currentTexture : 0);
@@ -607,6 +638,37 @@ void CPass::render () {
     this->setupRenderTexture ();
     this->setupRenderUniforms ();
     this->setupRenderReferenceUniforms ();
+
+    if (this->m_renderable.getId () == 21 && this->m_pass.shader == "effects/shake") {
+	static int diagCallCount = 0;
+	if (diagCallCount++ % 5 == 0) {
+	    const GLint boundsLoc = glGetUniformLocation (this->m_programID, "g_Bounds");
+	    const GLint ampLoc = glGetUniformLocation (this->m_programID, "g_Amp");
+	    const GLint speedLoc = glGetUniformLocation (this->m_programID, "g_Speed");
+	    const GLint timeLoc = glGetUniformLocation (this->m_programID, "g_Time");
+	    GLfloat bounds[2] = { -1, -1 };
+	    GLfloat amp = -1, speed = -1, time = -1;
+	    if (boundsLoc != -1) glGetUniformfv (this->m_programID, boundsLoc, bounds);
+	    if (ampLoc != -1) glGetUniformfv (this->m_programID, ampLoc, &amp);
+	    if (speedLoc != -1) glGetUniformfv (this->m_programID, speedLoc, &speed);
+	    if (timeLoc != -1) glGetUniformfv (this->m_programID, timeLoc, &time);
+
+	    constexpr float kHalfPi = 1.5707963267948966f;
+	    const float t = speed * time;
+	    const float fracVal = t / kHalfPi - std::floor (t / kHalfPi);
+	    const float raw = std::sin (fracVal * kHalfPi) * 0.498f + 0.5f;
+	    const float remapped = (bounds[1] - bounds[0]) != 0
+		? std::clamp ((raw - bounds[0]) * (1.0f / (bounds[1] - bounds[0])), 0.0f, 1.0f)
+		: -1.0f;
+
+	    sLog.out (
+		"TEMP-DIAG blink pulse (GPU-readback) programID=", this->m_programID, " g_Time(GPU)=", time,
+		" g_Amp(GPU)=", amp, " g_Speed(GPU)=", speed, " g_Bounds(GPU)=(", bounds[0], ",", bounds[1],
+		") -> rawOffset=", raw, " remappedOffset=", remapped
+	    );
+	}
+    }
+
     this->setupRenderAttributes ();
     this->renderGeometry ();
     this->cleanupRenderSetup ();
@@ -663,6 +725,50 @@ void CPass::setGeometryCallback (
 GLuint CPass::compileShader (const char* shader, GLuint type) {
     const GLuint shaderID = glCreateShader (type);
 
+    // Mesa mis-reads a vec3 uniform followed by a float used as vec4(vec3, float), use the g_Color4 the engine already exposes
+    std::string patched;
+
+    if (type == GL_FRAGMENT_SHADER) {
+	patched = shader;
+
+	const std::string declarations = "uniform vec3 g_Color;\nuniform float g_Alpha;";
+	const std::string construction = "vec4(g_Color, g_Alpha)";
+	const auto declarationsAt = patched.find (declarations);
+	const auto constructionAt = patched.find (construction);
+
+	if (declarationsAt != std::string::npos && constructionAt != std::string::npos) {
+	    patched.replace (constructionAt, construction.size (), "g_Color4");
+	    patched.replace (declarationsAt, declarations.size (), "uniform vec4 g_Color4;");
+
+	    // only safe when that was the sole use of both
+	    // the generated source keeps the original as an "#if 0" block at the end, that doesn't count
+	    const size_t codeEnd = std::min (patched.size (), patched.find ("#if 0"));
+	    const auto stillUsed = [&patched, codeEnd] (const std::string& name) {
+		size_t pos = 0;
+
+		while ((pos = patched.find (name, pos)) != std::string::npos && pos < codeEnd) {
+		    const bool wordStart = pos == 0 || !(std::isalnum (static_cast<unsigned char> (patched[pos - 1])) || patched[pos - 1] == '_');
+		    const size_t end = pos + name.size ();
+		    const bool wordEnd = end >= patched.size () || !(std::isalnum (static_cast<unsigned char> (patched[end])) || patched[end] == '_');
+
+		    if (wordStart && wordEnd) {
+			return true;
+		    }
+
+		    pos = end;
+		}
+
+		return false;
+	    };
+
+	    if (stillUsed ("g_Color") || stillUsed ("g_Alpha")) {
+		patched = shader;
+	    }
+	}
+
+	shader = patched.c_str ();
+    }
+
     glShaderSource (shaderID, 1, &shader, nullptr);
     glCompileShader (shaderID);
 
@@ -773,6 +879,29 @@ void CPass::setupShaders () {
     glDeleteShader (vertexShaderID);
     glDeleteShader (fragmentShaderID);
 
+    // bind each g_TextureN sampler to unit N explicitly, the translated GLSL collapses every layout(binding) to 0
+    {
+	glUseProgram (this->m_programID);
+	const bool diagTarget = this->m_renderable.getId () == 21 && shaderName == "effects/shake";
+	if (diagTarget) {
+	    sLog.out ("TEMP-DIAG texture-binding-fix: programID=", this->m_programID, " shader=", shaderName);
+	}
+	for (int index = 0; index <= 9; index++) {
+	    const std::string name = "g_Texture" + std::to_string (index);
+	    const GLint loc = glGetUniformLocation (this->m_programID, name.c_str ());
+	    if (loc != -1) {
+		glUniform1i (loc, index);
+	    }
+	    if (diagTarget) {
+		const GLenum err = glGetError ();
+		sLog.out (
+		    "TEMP-DIAG   ", name, " location=", loc, (loc != -1 ? " -> bound to unit " : " (not present)"),
+		    (loc != -1 ? std::to_string (index) : std::string ()), " glGetError=", err
+		);
+	    }
+	}
+    }
+
     // first setup the default values, these will be overwritten by future values
     this->setupShaderVariables ();
     this->setupUniforms ();
@@ -793,7 +922,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    // create chain entry
 	    this->m_textures[index] = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -812,7 +943,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -833,7 +966,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -863,7 +998,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -885,7 +1022,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
@@ -913,7 +1052,9 @@ void CPass::setupTextureUniforms () {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
-		: this->getContext ().resolveTexture (textureName);
+		: this->getContext ().resolveTexture (
+		    textureName, this->m_renderable.getScene ().getScene ().project
+		);
 
 	    const auto it = this->m_textures.find (index);
 	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {

+ 7 - 2
src/WallpaperEngine/Render/RenderContext.cpp

@@ -39,8 +39,11 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 void RenderContext::setWallpaper (const std::string& display, std::shared_ptr<CWallpaper> wallpaper) {
     wallpaper->setDestinationFramebuffer (this->m_app.getDestinationFramebuffer ());
     this->m_wallpapers.insert_or_assign (display, wallpaper);
+    this->pruneTextures ();
 }
 
+void RenderContext::pruneTextures () const { this->m_textureCache->prune (); }
+
 void RenderContext::setPause (const bool newState) const {
     for (const auto& wallpaper : this->m_wallpapers | std::views::values) {
 	wallpaper->setPause (newState);
@@ -55,8 +58,10 @@ const Drivers::VideoDriver& RenderContext::getDriver () const { return this->m_d
 
 const Drivers::Output::Output& RenderContext::getOutput () const { return this->m_driver.getOutput (); }
 
-std::shared_ptr<const TextureProvider> RenderContext::resolveTexture (const std::string& name) const {
-    return this->m_textureCache->resolve (name);
+std::shared_ptr<const TextureProvider> RenderContext::resolveTexture (
+    const std::string& name, const Data::Model::Project& project
+) const {
+    return this->m_textureCache->resolve (name, project);
 }
 
 const std::map<std::string, std::shared_ptr<CWallpaper>>& RenderContext::getWallpapers () const {

+ 4 - 1
src/WallpaperEngine/Render/RenderContext.h

@@ -40,11 +40,14 @@ namespace Render {
 	void render (Drivers::Output::OutputViewport* viewport);
 	void setWallpaper (const std::string& display, std::shared_ptr<CWallpaper> wallpaper);
 	void setPause (bool newState) const;
+	void pruneTextures () const;
 	[[nodiscard]] Input::InputContext& getInputContext () const;
 	[[nodiscard]] const WallpaperApplication& getApp () const;
 	[[nodiscard]] const Drivers::VideoDriver& getDriver () const;
 	[[nodiscard]] const Drivers::Output::Output& getOutput () const;
-	[[nodiscard]] std::shared_ptr<const TextureProvider> resolveTexture (const std::string& name) const;
+	[[nodiscard]] std::shared_ptr<const TextureProvider> resolveTexture (
+	    const std::string& name, const Data::Model::Project& project
+	) const;
 	[[nodiscard]] const std::map<std::string, std::shared_ptr<CWallpaper>>& getWallpapers () const;
 	[[nodiscard]] Media::MediaSource& getMediaSource () const;
 

+ 27 - 2
src/WallpaperEngine/Render/Shaders/GLSLContext.cpp

@@ -3,6 +3,8 @@
 
 #include <cassert>
 #include <memory>
+#include <mutex>
+#include <unordered_map>
 
 #include "SPIRV/GlslangToSpv.h"
 #include "glslang/Include/ResourceLimits.h"
@@ -132,6 +134,22 @@ GLSLContext& GLSLContext::get () {
 }
 
 std::pair<std::string, std::string> GLSLContext::toGlsl (const std::string& vertex, const std::string& fragment) {
+    // pure function of the two sources, and passes get rebuilt often
+    static std::mutex cacheMutex;
+    static std::unordered_map<std::string, std::pair<std::string, std::string>> cache;
+
+    std::string cacheKey = vertex;
+    cacheKey += '\0';
+    cacheKey += fragment;
+
+    {
+	std::lock_guard lock (cacheMutex);
+
+	if (const auto cached = cache.find (cacheKey); cached != cache.end ()) {
+	    return cached->second;
+	}
+    }
+
     glslang::TShader vertexShader (EShLangVertex);
 
     const char* vertexSource = vertex.c_str ();
@@ -190,8 +208,15 @@ std::pair<std::string, std::string> GLSLContext::toGlsl (const std::string& vert
     options.force_zero_initialized_variables = true;
     fragmentCompiler.set_common_options (options);
 
-    return { vertexCompiler.compile () + "#if 0\n" + vertex + "\n#endif",
-	     fragmentCompiler.compile () + "#if 0\n" + fragment + "\n#endif" };
+    std::pair<std::string, std::string> result = { vertexCompiler.compile () + "#if 0\n" + vertex + "\n#endif",
+						   fragmentCompiler.compile () + "#if 0\n" + fragment + "\n#endif" };
+
+    {
+	std::lock_guard lock (cacheMutex);
+	cache.emplace (std::move (cacheKey), result);
+    }
+
+    return result;
 }
 
 std::unique_ptr<GLSLContext> GLSLContext::sInstance = nullptr;

+ 249 - 4
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -1,10 +1,13 @@
 #include "ShaderUnit.h"
 
 #include "WallpaperEngine/Logging/Log.h"
+#include <cctype>
 #include <exception>
+#include <mutex>
 #include <regex>
 #include <stack>
 #include <string>
+#include <unordered_map>
 #include <utility>
 
 #include "GLSLContext.h"
@@ -80,6 +83,7 @@ void ShaderUnit::preprocess () {
     this->preprocessRequires ();
     this->preprocessVariables ();
     this->preprocessBalanceConditionals ();
+    this->preprocessSwizzledDeclarations ();
 
     const std::string from = "gl_FragColor";
     const std::string to = "out_FragColor";
@@ -374,6 +378,190 @@ void ShaderUnit::preprocessBalanceConditionals () {
     }
 }
 
+void ShaderUnit::preprocessSwizzledDeclarations () {
+    static const std::regex swizzledDecl (
+	R"(\b(varying|uniform|attribute)(\s+[A-Za-z0-9_]+\s+[A-Za-z_][A-Za-z0-9_]*)\.[xyzwrgba]+(\s*;))"
+    );
+
+    const std::string original = this->m_preprocessed;
+    this->m_preprocessed = std::regex_replace (this->m_preprocessed, swizzledDecl, "$1$2$3");
+
+    if (this->m_preprocessed != original) {
+	sLog.out ("Dropped swizzle from declaration name in shader ", this->m_file);
+    }
+}
+
+std::string ShaderUnit::applyVectorTruncationCompatibility (std::string source) const {
+    static const std::regex vectorDecl (R"(\b(vec[234])\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
+    static const std::regex narrowAssign (R"(\b(vec[23])\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([^;{}]+);)");
+
+    // name -> width, 0 when the same name is declared with different widths in different scopes
+    std::unordered_map<std::string, int> widths;
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), vectorDecl); it != std::sregex_iterator ();
+	 ++it) {
+	const int width = (*it)[1].str ().back () - '0';
+	const std::string name = (*it)[2].str ();
+	const auto found = widths.find (name);
+
+	if (found == widths.end ()) {
+	    widths.emplace (name, width);
+	} else if (found->second != width) {
+	    found->second = 0;
+	}
+    }
+
+    static const char* swizzles[] = { "", "", ".xy", ".xyz" };
+
+    std::string result;
+    size_t last = 0;
+    bool changed = false;
+
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), narrowAssign);
+	 it != std::sregex_iterator (); ++it) {
+	const int targetWidth = (*it)[1].str ().back () - '0';
+	const size_t exprStart = it->position (2);
+	const std::string expr = (*it)[2].str ();
+
+	std::string fixed;
+	int depth = 0;
+
+	for (size_t i = 0; i < expr.size ();) {
+	    const char c = expr[i];
+
+	    if (c == '(' || c == '[') {
+		depth++;
+	    } else if (c == ')' || c == ']') {
+		depth--;
+	    }
+
+	    if (!(std::isalpha (static_cast<unsigned char> (c)) || c == '_')) {
+		fixed += c;
+		i++;
+		continue;
+	    }
+
+	    size_t end = i;
+	    while (end < expr.size () && (std::isalnum (static_cast<unsigned char> (expr[end])) || expr[end] == '_')) {
+		end++;
+	    }
+
+	    const std::string ident = expr.substr (i, end - i);
+	    fixed += ident;
+
+	    size_t before = i;
+	    while (before > 0 && std::isspace (static_cast<unsigned char> (expr[before - 1]))) {
+		before--;
+	    }
+	    size_t after = end;
+	    while (after < expr.size () && std::isspace (static_cast<unsigned char> (expr[after]))) {
+		after++;
+	    }
+
+	    const bool member = before > 0 && expr[before - 1] == '.';
+	    const bool accessed = after < expr.size () && (expr[after] == '.' || expr[after] == '(' || expr[after] == '[');
+	    const auto found = widths.find (ident);
+
+	    if (depth == 0 && !member && !accessed && found != widths.end () && found->second > targetWidth) {
+		fixed += swizzles[targetWidth];
+		changed = true;
+	    }
+
+	    i = end;
+	}
+
+	result.append (source, last, exprStart - last);
+	result += fixed;
+	last = exprStart + expr.size ();
+    }
+
+    if (!changed) {
+	return source;
+    }
+
+    result.append (source, last, std::string::npos);
+    sLog.out ("Applied vector truncation compatibility in shader ", this->m_file);
+
+    return result;
+}
+
+std::string ShaderUnit::applyFloatConditionCompatibility (std::string source) const {
+    static const std::regex floatDecl (R"(\b(float|vec[234]|int|bool|mat[234])\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
+    static const std::regex ternaryCond (R"(\b([A-Za-z_][A-Za-z0-9_]*)\s*\?)");
+    static const std::regex ifCond (R"(\bif\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\))");
+
+    // names that are only ever declared as float
+    std::unordered_map<std::string, bool> onlyFloat;
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), floatDecl); it != std::sregex_iterator ();
+	 ++it) {
+	const bool isFloat = (*it)[1].str () == "float";
+	const std::string name = (*it)[2].str ();
+	const auto found = onlyFloat.find (name);
+
+	if (found == onlyFloat.end()) {
+	    onlyFloat.emplace (name, isFloat);
+	} else if (!isFloat) {
+	    found->second = false;
+	}
+    }
+
+    std::string result;
+    size_t last = 0;
+    bool changed = false;
+
+    auto rewrite = [&] (const std::regex& pattern, bool ternary) {
+	result.clear ();
+	last = 0;
+
+	for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), pattern); it != std::sregex_iterator ();
+	     ++it) {
+	    const std::string name = (*it)[1].str ();
+	    const auto found = onlyFloat.find (name);
+	    if (found == onlyFloat.end () || !found->second) {
+		continue;
+	    }
+
+	    const size_t nameStart = it->position (1);
+	    const size_t nameEnd = nameStart + name.size ();
+
+	    if (ternary) {
+		size_t before = nameStart;
+		while (before > 0 && std::isspace (static_cast<unsigned char> (source[before - 1]))) {
+		    before--;
+		}
+		if (before == 0) {
+		    continue;
+		}
+
+		// only where the float is the whole condition (or a whole && / || operand), not e.g. `x == f ? ..`
+		const char prev = source[before - 1];
+		const char beforePrev = before > 1 ? source[before - 2] : ' ';
+		const bool assignment = prev == '=' && std::string ("=!<>").find (beforePrev) == std::string::npos;
+
+		if (!assignment && prev != '(' && prev != ',' && prev != '&' && prev != '|') {
+		    continue;
+		}
+	    }
+
+	    result.append (source, last, nameStart - last);
+	    result += "(" + name + " != 0.0)";
+	    last = nameEnd;
+	    changed = true;
+	}
+
+	result.append (source, last, std::string::npos);
+	source = result;
+    };
+
+    rewrite (ternaryCond, true);
+    rewrite (ifCond, false);
+
+    if (changed) {
+	sLog.out ("Applied float condition compatibility in shader ", this->m_file);
+    }
+
+    return source;
+}
+
 std::string ShaderUnit::applyLinkedVaryingCompatibility (std::string source) const {
     if (this->m_type != GLSLContext::UnitType_Vertex || this->m_link == nullptr) {
 	return source;
@@ -453,10 +641,14 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
 
 	// only shadow varyings the shader actually reassigns - a plain read-only "in" is fine as-is,
 	// and touching the declaration unnecessarily risks breaking a shader that works today
+	// a typed local of the same name already shadows the varying, blank the write or the shadow local collides
+	const std::regex localDecl ("\\b(?:vec[234]|float|int|bool)\\s+" + name + "\\b");
+	const std::string withoutLocals = std::regex_replace (source, localDecl, " ");
+
 	const std::regex assignmentUse (
 	    "\\b" + name + "\\b(?:\\.[xyzwrgba]+)?\\s*(?:=(?!=)|\\+=|-=|\\*=|/=)"
 	);
-	if (!std::regex_search (source, assignmentUse)) {
+	if (!std::regex_search (withoutLocals, assignmentUse)) {
 	    continue;
 	}
 
@@ -695,7 +887,24 @@ const std::string& ShaderUnit::compile () {
     static const std::regex log10Definition (
 	R"(\b(?:void|float|int|uint|bool|vec[234]|ivec[234]|uvec[234]|bvec[234]|mat[234](?:x[234])?)\s+log10\s*\()"
     );
-    if (!std::regex_search (this->m_content, log10Definition)) {
+    // memoized per source, compile() runs again for every pass a text layer rebuilds
+    static std::mutex cacheMutex;
+    static std::unordered_map<std::string, bool> definesLog10;
+    static std::unordered_map<std::string, std::string> compatCache;
+
+    bool hasLog10 = false;
+    {
+	std::lock_guard lock (cacheMutex);
+	auto found = definesLog10.find (this->m_content);
+
+	if (found == definesLog10.end ()) {
+	    found = definesLog10.emplace (this->m_content, std::regex_search (this->m_content, log10Definition)).first;
+	}
+
+	hasLog10 = found->second;
+    }
+
+    if (!hasLog10) {
 	this->m_final += "#define log10(x) (log2(x) * 0.301029995663981)\n";
     }
 
@@ -759,10 +968,46 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    this->m_final += this->applyFragmentVaryingShadowCompatibility (
-	this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed))
+    // the header above already encodes the unit type and every define, so it works as the key
+    std::string cacheKey = this->m_final;
+    cacheKey += '\x1f';
+    cacheKey += this->m_preprocessed;
+    cacheKey += '\x1f';
+
+    if (this->m_link != nullptr) {
+	cacheKey += this->m_link->m_preprocessed;
+    }
+
+    for (const auto& [name, value] : this->m_combos) {
+	cacheKey += '\x1f';
+	cacheKey += name;
+	cacheKey += '=';
+	cacheKey += std::to_string (value);
+    }
+
+    {
+	std::lock_guard lock (cacheMutex);
+
+	if (const auto cached = compatCache.find (cacheKey); cached != compatCache.end ()) {
+	    this->m_final += cached->second;
+
+	    return this->m_final;
+	}
+    }
+
+    const std::string compat = this->applyFloatConditionCompatibility (
+	this->applyVectorTruncationCompatibility (this->applyFragmentVaryingShadowCompatibility (
+	    this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed))
+	))
     );
 
+    {
+	std::lock_guard lock (cacheMutex);
+	compatCache.emplace (std::move (cacheKey), compat);
+    }
+
+    this->m_final += compat;
+
     // actual GLSL compilation happens in the pass, which has the context this unit doesn't
     return this->m_final;
 }

+ 9 - 0
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -50,6 +50,9 @@ private:
      * Comments out any #endif without a matching #if/#ifdef/#ifndef so preprocessing doesn't fail outright.
      */
     void preprocessBalanceConditionals ();
+    /** Some workshop shaders declare a varying/uniform with a swizzle in its name (`varying vec4 v_Size.xy;`),
+     *  which the original compiler tolerates and glslang rejects. Drops the swizzle from the declaration. */
+    void preprocessSwizzledDeclarations ();
     /** Resolves a #require module name (e.g. "LightingV1") to generated GLSL code, or "" if unknown */
     [[nodiscard]] std::string resolveRequireModule (const std::string& moduleName) const;
     /** Generates the LightingV1 module stub (PerformLighting_V1 function) */
@@ -62,6 +65,12 @@ private:
      *  (GLSL 330 core) turns into an l-value error since `in` is read-only. Shadows any varying that's
      *  actually written to with a same-named local at the top of main(), copied from the true input. */
     [[nodiscard]] std::string applyFragmentVaryingShadowCompatibility (std::string source) const;
+    /** HLSL silently truncates a wider vector when it is assigned to a narrower one (`vec2 d = someVec4 * someVec2;`),
+     *  GLSL rejects it. Swizzles top-level wider-vector operands down to the declared width. */
+    [[nodiscard]] std::string applyVectorTruncationCompatibility (std::string source) const;
+    /** 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;
 
     void parseComboConfiguration (const std::string& content, int defaultValue = 0);
     void parseParameterConfiguration (const std::string& type, const std::string& name, const std::string& content);

+ 49 - 17
src/WallpaperEngine/Render/TextureCache.cpp

@@ -51,32 +51,51 @@ TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (con
 
 TextureCache::~TextureCache () { this->m_mediaCallback (); }
 
-std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string& filename) {
-    if (const auto found = this->m_textureCache.find (filename); found != this->m_textureCache.end ()) {
+std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string& filename, const Project& requester) {
+    if (const auto shared = this->m_sharedTextures.find (filename); shared != this->m_sharedTextures.end ()) {
+	return shared->second;
+    }
+
+    const auto key = std::make_pair (&requester, filename);
+
+    if (const auto found = this->m_textureCache.find (key); found != this->m_textureCache.end ()) {
 	return found->second;
     }
 
-    // fall back to searching every loaded background's container, in case it belongs to another one
-    for (const auto& project : this->getContext ().getApp ().getBackgrounds () | std::views::values) {
-	try {
-	    const auto contents = project->assetLocator->texture (filename);
-	    auto stream = BinaryReader (contents);
+    const auto load = [&] (const Project& project) {
+	const auto contents = project.assetLocator->texture (filename);
+	auto stream = BinaryReader (contents);
 
-	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
-		std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
-		return project->assetLocator->readString (fullPath);
-	    };
+	auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
+	    std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
+	    return project.assetLocator->readString (fullPath);
+	};
 
-	    auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
-	    auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
+	auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
+	auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
 
 #if !NDEBUG
-	    glObjectLabel (GL_TEXTURE, texture->getTextureID (0), -1, filename.c_str ());
+	glObjectLabel (GL_TEXTURE, texture->getTextureID (0), -1, filename.c_str ());
 #endif
 
-	    this->store (filename, texture);
+	this->m_textureCache.insert_or_assign (key, texture);
+
+	return texture;
+    };
+
+    try {
+	return load (requester);
+    } catch (AssetLoadException&) {
+	// not shipped by the requesting project itself, it might still live in another loaded one
+    }
+
+    for (const auto& project : this->getContext ().getApp ().getBackgrounds () | std::views::values) {
+	if (project.get () == &requester) {
+	    continue;
+	}
 
-	    return texture;
+	try {
+	    return load (*project);
 	} catch (AssetLoadException&) {
 	    // ignored, this happens if we're looking at the wrong background
 	}
@@ -87,5 +106,18 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 }
 
 void TextureCache::store (const std::string& name, std::shared_ptr<const TextureProvider> texture) {
-    this->m_textureCache.insert_or_assign (name, texture);
+    this->m_sharedTextures.insert_or_assign (name, texture);
+}
+
+void TextureCache::prune () {
+    const auto& backgrounds = this->getContext ().getApp ().getBackgrounds ();
+
+    // entries of a gone project must not be inherited by a new one allocated at the same address
+    std::erase_if (this->m_textureCache, [&backgrounds] (const auto& entry) {
+	const auto alive = std::ranges::any_of (backgrounds, [&entry] (const auto& background) {
+	    return background.second.get () == entry.first.first;
+	});
+
+	return !alive || entry.second.use_count () == 1;
+    });
 }

+ 11 - 3
src/WallpaperEngine/Render/TextureCache.h

@@ -4,9 +4,11 @@
 #include <map>
 #include <memory>
 #include <string>
+#include <utility>
 
 #include "TextureProvider.h"
 #include "WallpaperEngine/Render/Helpers/ContextAware.h"
+#include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Render/RenderContext.h"
 
 using namespace WallpaperEngine::Render;
@@ -24,15 +26,21 @@ public:
     explicit TextureCache (RenderContext& context);
     ~TextureCache () override;
 
-    /** Returns the cached texture for filename, loading it from the containers first if needed */
-    std::shared_ptr<const TextureProvider> resolve (const std::string& filename);
+    /** Returns the texture for filename as seen by the given project, cached per project since wallpapers reuse file names */
+    std::shared_ptr<const TextureProvider> resolve (const std::string& filename, const Data::Model::Project& project);
 
+    /** Registers a texture that is shared by every project (media thumbnails and such) */
     void store (const std::string& name, std::shared_ptr<const TextureProvider> texture);
 
+    /** Drops every cached texture that no wallpaper is using anymore */
+    void prune ();
+
 private:
     std::shared_ptr<const AlbumTexture> m_previousThumbnail = nullptr;
     std::shared_ptr<const AlbumTexture> m_currentThumbnail = nullptr;
-    std::map<std::string, std::shared_ptr<const TextureProvider>> m_textureCache = {};
+    std::map<std::string, std::shared_ptr<const TextureProvider>> m_sharedTextures = {};
+    std::map<std::pair<const Data::Model::Project*, std::string>, std::shared_ptr<const TextureProvider>>
+	m_textureCache = {};
     std::function<void ()> m_mediaCallback;
 };
 } // namespace WallpaperEngine::Render

+ 50 - 0
src/WallpaperEngine/Render/WallpaperState.cpp

@@ -216,8 +216,54 @@ void applyZoomToAxis (float& start, float& end, float zoom) {
     start = center - half / zoom;
     end = center + half / zoom;
 }
+
+// Slides the UV window toward one edge without changing its size, offset in [-1, 1]; no-op when nothing is cropped on the axis
+void applyOffsetToAxis (float& start, float& end, float offset) {
+    if (offset == 0.0f) {
+	return;
+    }
+
+    const bool inverted = start > end;
+    float lo = inverted ? end : start;
+    float hi = inverted ? start : end;
+    const float size = hi - lo;
+    const float slack = std::max (0.0f, 1.0f - size);
+
+    if (slack <= 0.0f) {
+	return;
+    }
+
+    const float shift = std::clamp (offset, -1.0f, 1.0f) * slack / 2.0f;
+    lo = std::clamp (lo + shift, 0.0f, 1.0f - size);
+    hi = lo + size;
+
+    if (inverted) {
+	start = hi;
+	end = lo;
+    } else {
+	start = lo;
+	end = hi;
+    }
+}
 } // namespace
 
+float WallpaperState::getOffsetX () const { return this->m_offsetX; }
+
+float WallpaperState::getOffsetY () const { return this->m_offsetY; }
+
+void WallpaperState::setOffset (float offsetX, float offsetY) {
+    offsetX = std::clamp (offsetX, -1.0f, 1.0f);
+    offsetY = std::clamp (offsetY, -1.0f, 1.0f);
+
+    if (this->m_offsetX == offsetX && this->m_offsetY == offsetY) {
+	return;
+    }
+
+    this->m_offsetX = offsetX;
+    this->m_offsetY = offsetY;
+    this->m_uvsDirty = true;
+}
+
 int WallpaperState::getViewportWidth () const { return this->m_viewport.width; }
 
 int WallpaperState::getViewportHeight () const { return this->m_viewport.height; }
@@ -264,4 +310,8 @@ void WallpaperState::updateState (
 	applyZoomToAxis (this->m_UVs.ustart, this->m_UVs.uend, this->m_zoom);
 	applyZoomToAxis (this->m_UVs.vstart, this->m_UVs.vend, this->m_zoom);
     }
+
+    // applied after zoom, a more zoomed-in view has more room to pan
+    applyOffsetToAxis (this->m_UVs.ustart, this->m_UVs.uend, this->m_offsetX);
+    applyOffsetToAxis (this->m_UVs.vstart, this->m_UVs.vend, this->m_offsetY);
 }

+ 9 - 2
src/WallpaperEngine/Render/WallpaperState.h

@@ -64,6 +64,12 @@ public:
      */
     void setZoom (float zoom);
 
+    [[nodiscard]] float getOffsetX () const;
+    [[nodiscard]] float getOffsetY () const;
+
+    /** Re-centers the crop window, each axis in [-1, 1] (0 = centered), takes effect on the next frame like setZoom() */
+    void setOffset (float offsetX, float offsetY);
+
     [[nodiscard]] int getViewportWidth () const;
     [[nodiscard]] int getViewportHeight () const;
     [[nodiscard]] int getProjectionWidth () const;
@@ -93,8 +99,9 @@ private:
     TextureUVsScaling m_textureUVsMode = TextureUVsScaling::DefaultUVs;
     uint32_t m_clampingMode = TextureFlags_NoFlags;
     float m_zoom = 1.0f;
-    // Set whenever the scaling mode or zoom changes live, so hasChanged() reports a change even if the
-    // viewport/projection stayed the same, forcing the UVs to be recomputed on the next frame
+    float m_offsetX = 0.0f;
+    float m_offsetY = 0.0f;
+    // set when scaling mode, zoom or offset change live, forces the UVs to be recomputed
     bool m_uvsDirty = false;
 };
 } // namespace WallpaperEngine::Render

+ 153 - 29
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -7,6 +7,7 @@
 
 #include "CScene.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
@@ -84,7 +85,8 @@ CScene::CScene (
     const auto computeChainDepth = [&scene] (const Object* object) {
 	int depth = 0;
 	const Object* current = object;
-	while (current->parent.has_value () && depth < kMaxChainDepth) {
+	// plain group parents are just layer-tree nesting and keep array order
+	while (current->parent.has_value () && current->attachment.has_value () && depth < kMaxChainDepth) {
 	    const auto it = std::ranges::find_if (
 		scene->objects, [&current] (const auto& o) { return o->id == current->parent.value (); }
 	    );
@@ -170,7 +172,9 @@ CScene::CScene (
 	    continue;
 	}
 	const glm::vec3 origin = object->origin->value->getVec3 ();
-	const glm::vec2 half = image->size / 2.0f;
+	// the declared size alone isn't the on-screen footprint, the object's scale matters too
+	const glm::vec3 scale = image->scale->value->getVec3 ();
+	const glm::vec2 half = image->size * glm::abs (glm::vec2 (scale.x, scale.y)) / 2.0f;
 	candidates.push_back ({ object, { origin.x - half.x, origin.y - half.y }, { origin.x + half.x, origin.y + half.y } });
     }
 
@@ -337,7 +341,6 @@ Render::CObject* CScene::createObject (const Object& object) {
 
 Render::CObject* CScene::dispatchObjectType (const Object& object) {
     Render::CObject* renderObject = nullptr;
-
     if (object.is<Image> ()) {
 	renderObject = new Objects::CImage (*this, *object.as<Image> ());
     } else if (object.is<Sound> ()) {
@@ -359,8 +362,9 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
 
 	renderObject = new Objects::CParticle (*this, particleData);
     } else {
-	sLog.error ("Unknown object type, creating placeholder, empty object: ", object.id);
-	renderObject = new CObject (*this, object);
+	// group/locator nodes can still carry scripts, and the shared-state bootstrap script often lives on one
+	sLog.debug ("Creating group object without renderable: ", object.id);
+	renderObject = new Scripting::ScriptableObject (*this, object);
     }
 
     try {
@@ -413,17 +417,26 @@ Camera& CScene::getCamera () const { return *this->m_camera; }
 void CScene::renderFrame (const glm::ivec4& viewport) {
     this->updateMouse (viewport);
 
-    if (this->getScene ().camera.parallax.enabled->value->getBool ()
-	&& !this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
-	const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
-	const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
-	const float delay = glm::clamp (
-	    this->getScene ().camera.parallax.delay->value->getFloat () * (g_Time - g_TimeLast), 0.0f, 1.0f
-	);
-
-	const glm::vec2 centeredMouse = this->m_mousePosition - glm::vec2 (0.5f, 0.5f);
-	this->m_parallaxDisplacement
-	    = glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
+    if (this->getScene ().camera.parallax.enabled->value->getBool ()) {
+	// the wallpaper's own position rides through the same per-layer depth/clamp mechanism as mouse parallax, halved to match its range
+	// X keeps WallpaperState's sign flip, Y does not because this displacement is applied after the Y-up conversion
+	const glm::vec2 positionBias
+	    = { -this->getState ().getOffsetX () * 0.5f, this->getState ().getOffsetY () * 0.5f };
+
+	if (this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	    // no mouse contribution left to smooth toward, apply position directly
+	    this->m_parallaxDisplacement = positionBias;
+	} else {
+	    const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
+	    const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
+	    const float delay = glm::clamp (
+		this->getScene ().camera.parallax.delay->value->getFloat () * (g_Time - g_TimeLast), 0.0f, 1.0f
+	    );
+
+	    const glm::vec2 centeredMouse = this->m_mousePosition - glm::vec2 (0.5f, 0.5f) + positionBias;
+	    this->m_parallaxDisplacement
+		= glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
+	}
     }
 
     this->getScriptEngine ().tick ();
@@ -470,6 +483,10 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	    continue;
 	}
 
+	if (this->isHiddenByAncestor (*cur)) {
+	    continue;
+	}
+
 	cur->render ();
     }
 }
@@ -496,6 +513,10 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
     this->m_mousePosition.y = uvs.vstart + mouseY * (uvs.vend - uvs.vstart);
 }
 
+const Data::Model::Properties& CScene::getUserProperties () const {
+    return this->getWallpaperData ().project.properties;
+}
+
 const Scene& CScene::getScene () const { return *this->getWallpaperData ().as<Scene> (); }
 
 int CScene::getWidth () const { return this->m_camera->getWidth (); }
@@ -525,11 +546,74 @@ const glm::vec2* CScene::getParallaxDisplacement () const { return &this->m_para
 
 const std::vector<CObject*>& CScene::getObjectsByRenderOrder () const { return this->m_objectsByRenderOrder; }
 
+bool CScene::isHiddenByAncestor (const CObject& object) const {
+    const auto& appContext = this->getContext ().getApp ().getContext ();
+    const Object* current = &object.getObject ();
+
+    // objects nest through "parent"; a child is only shown while every group above it is, whatever
+    // its own visible property says (the guard keeps a malformed parent loop from spinning)
+    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
+	const auto* parent = this->getObject (current->parent.value ());
+
+	if (parent == nullptr) {
+	    return false;
+	}
+
+	const Object& data = parent->getObject ();
+	const auto override = appContext.resolveObjectVisibility (parent->getId (), data.name);
+	bool visible;
+
+	if (override.has_value ()) {
+	    visible = override.value ();
+	} else if (data.is<Image> ()) {
+	    visible = data.as<Image> ()->visible->value->getBool ();
+	} else if (data.is<Text> ()) {
+	    visible = data.as<Text> ()->visible->value->getBool ();
+	} else if (data.is<Particle> ()) {
+	    visible = data.as<Particle> ()->visible->value->getBool ();
+	} else {
+	    visible = data.groupVisible->value->getBool ();
+	}
+
+	if (!visible) {
+	    return true;
+	}
+
+	current = &data;
+    }
+
+    return false;
+}
+
 const CObject* CScene::getObject (int id) const {
     const auto object = this->m_objects.find (id);
     return object == this->m_objects.end () ? nullptr : object->second;
 }
 
+void CScene::setSoundPlaying (int id, bool playing) {
+    this->m_soundPlayRequests[id] = playing;
+
+    const auto object = this->m_objects.find (id);
+
+    if (object == this->m_objects.end () || !object->second->is<Objects::CSound> ()) {
+	return;
+    }
+
+    auto* sound = object->second->as<Objects::CSound> ();
+
+    if (playing) {
+	sound->play ();
+    } else {
+	sound->stop ();
+    }
+}
+
+std::optional<bool> CScene::getSoundPlayRequest (int id) const {
+    const auto request = this->m_soundPlayRequests.find (id);
+
+    return request == this->m_soundPlayRequests.end () ? std::nullopt : std::optional<bool> (request->second);
+}
+
 int CScene::getObjectIndex (const CObject* object) const {
     const auto it = std::ranges::find (this->m_objectsByRenderOrder, object);
 
@@ -542,27 +626,67 @@ int CScene::getObjectIndex (const CObject* object) const {
 
 Render::CObject* CScene::createLayer (const std::string& imagePath) {
     const int id = this->m_nextDynamicLayerId++;
+    const std::string name = "scriptlayer_" + std::to_string (id);
 
     // same minimal-object-JSON approach the constructor uses for the bloom layer, so this gets the
     // same defaults a real scene.json image object would
-    const JSON layerJson = {
-	{ "id", id },
-	{ "name", "scriptlayer_" + std::to_string (id) },
-	{ "image", imagePath },
-	{ "visible", true },
+    const auto tryBuild = [&] (const std::string& path) -> Render::CObject* {
+	const JSON layerJson = {
+	    { "id", id },
+	    { "name", name },
+	    { "image", path },
+	    { "visible", true },
+	};
+
+	auto objectData = ObjectParser::parse (layerJson, this->getScene ().project);
+	Render::CObject* renderObject = this->createObject (*objectData);
+
+	if (renderObject == nullptr) {
+	    return nullptr;
+	}
+
+	this->m_dynamicObjectData.emplace_back (std::move (objectData));
+	this->m_objectsByRenderOrder.push_back (renderObject);
+
+	return renderObject;
     };
 
-    auto objectData = ObjectParser::parse (layerJson, this->getScene ().project);
-    Render::CObject* renderObject = this->createObject (*objectData);
+    // createObject() throws on a missing asset, and unwinding a C++ exception through the QuickJS callback is undefined behavior
+    try {
+	return tryBuild (imagePath);
+    } catch (const std::exception& e) {
+	// scripts written against a workshop dependency's original layout use the un-prefixed name, try the prefixed copy first
+	if (const auto alias = this->getScene ().project.assetLocator->resolveWorkshopDependencyAlias (imagePath);
+	    alias.has_value ()) {
+	    try {
+		sLog.out (
+		    "createLayer: '", imagePath, "' not found, found and using workshop-dependency copy '",
+		    alias->string (), "' instead"
+		);
+		return tryBuild (alias->string ());
+	    } catch (const std::exception& aliasError) {
+		sLog.error ("createLayer: workshop-dependency copy '", alias->string (), "' also failed: ", aliasError.what ());
+	    }
+	} else {
+	    sLog.error ("createLayer failed for '", imagePath, "', falling back to an invisible placeholder: ", e.what ());
+	}
 
-    if (renderObject == nullptr) {
-	return nullptr;
-    }
+	// scripts write into createLayer()'s result without a null check, so fall back to a scriptable, non-rendering placeholder
+	try {
+	    const JSON placeholderJson = { { "id", id }, { "name", name }, { "visible", true } };
+	    auto objectData = ObjectParser::parse (placeholderJson, this->getScene ().project);
+	    auto* renderObject = new Scripting::ScriptableObject (*this, *objectData);
 
-    this->m_dynamicObjectData.emplace_back (std::move (objectData));
-    this->m_objectsByRenderOrder.push_back (renderObject);
+	    this->m_objects.emplace (renderObject->getId (), renderObject);
+	    this->m_dynamicObjectData.emplace_back (std::move (objectData));
+	    this->m_objectsByRenderOrder.push_back (renderObject);
 
-    return renderObject;
+	    return renderObject;
+	} catch (const std::exception& placeholderError) {
+	    sLog.error ("createLayer placeholder fallback also failed: ", placeholderError.what ());
+	    return nullptr;
+	}
+    }
 }
 
 void CScene::sortLayer (CObject* object, int index) {

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

@@ -22,6 +22,9 @@ public:
 
     ~CScene () override;
 
+    /** The project's user properties (project.json "properties"), keyed by name */
+    [[nodiscard]] const Data::Model::Properties& getUserProperties () const;
+
     [[nodiscard]] Scripting::ScriptEngine& getScriptEngine () const;
     [[nodiscard]] Camera& getCamera () const;
 
@@ -42,10 +45,16 @@ public:
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const CObject* getObject (int id) const;
+    /** True when any group above the object (through "parent") is hidden */
+    [[nodiscard]] bool isHiddenByAncestor (const CObject& object) const;
     [[nodiscard]] int getObjectIndex (const CObject* object) const;
 
     void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
 
+    /** Script play()/stop() request for a Sound object, remembered because scripts can ask before the CSound exists */
+    void setSoundPlaying (int id, bool playing);
+    [[nodiscard]] std::optional<bool> getSoundPlayRequest (int id) const;
+
     /** Creates a new image layer from a model json at runtime, appended to the render order. Backs
      *  the scripting API's thisScene.createLayer(). Returns nullptr if the model couldn't be set up. */
     Render::CObject* createLayer (const std::string& imagePath);
@@ -69,6 +78,7 @@ private:
     ObjectUniquePtr m_bloomObjectData;
     CObject* m_bloomObject = nullptr;
     std::map<int, CObject*> m_objects = {};
+    std::map<int, bool> m_soundPlayRequests = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
     std::vector<DynamicValue*> m_scriptedValues = {};
     // owns the synthesized model data backing createLayer()'d objects; must outlive the CObject

+ 1218 - 0
src/WallpaperEngine/Render/Wallpapers/CSplat.cpp

@@ -0,0 +1,1218 @@
+#include "CSplat.h"
+
+#include <algorithm>
+#include <cmath>
+#include <condition_variable>
+#include <cstdio>
+#include <cstring>
+#include <ctime>
+#include <limits>
+#include <mutex>
+#include <optional>
+#include <thread>
+
+#include <glm/gtc/constants.hpp>
+#include <glm/gtc/matrix_transform.hpp>
+#include <glm/gtc/type_ptr.hpp>
+
+#include "WallpaperEngine/Application/ApplicationContext.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+#include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Model/Wallpaper.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Splat/SogLoader.h"
+
+using namespace WallpaperEngine::Render;
+using namespace WallpaperEngine::Render::Wallpapers;
+
+namespace WallpaperEngine::Render::Wallpapers {
+// Orders splats back to front along the view direction with a 16 bit counting sort on a worker thread,
+// the result is a list of splat indices to draw as instances.
+class SplatSorter {
+public:
+    SplatSorter (std::vector<glm::vec3> centers, std::vector<uint32_t> ids) :
+	m_centers (std::move (centers)), m_ids (std::move (ids)) {
+	this->m_worker = std::thread (&SplatSorter::run, this);
+    }
+
+    ~SplatSorter () {
+	{
+	    std::lock_guard lock (this->m_mutex);
+	    this->m_quit = true;
+	}
+
+	this->m_wake.notify_one ();
+	this->m_worker.join ();
+    }
+
+    void sortNow (const glm::vec3& position, const glm::vec3& forward, std::vector<uint32_t>& out) const {
+	Scratch scratch;
+	this->sort (position, forward, scratch, out);
+    }
+
+    // Asks the worker for a new order; if it is still busy with an older request, that one is replaced
+    void request (const glm::vec3& position, const glm::vec3& forward) {
+	{
+	    std::lock_guard lock (this->m_mutex);
+	    this->m_requestPosition = position;
+	    this->m_requestForward = forward;
+	    this->m_hasRequest = true;
+	}
+
+	this->m_wake.notify_one ();
+    }
+
+    bool takeResult (std::vector<uint32_t>& out) {
+	std::lock_guard lock (this->m_mutex);
+
+	if (!this->m_hasResult) {
+	    return false;
+	}
+
+	out = std::move (this->m_result);
+	this->m_hasResult = false;
+
+	return true;
+    }
+
+private:
+    struct Scratch {
+	std::vector<float> depths;
+	std::vector<uint32_t> keys;
+	std::vector<uint32_t> counts;
+    };
+
+    void sort (const glm::vec3& position, const glm::vec3& forward, Scratch& scratch, std::vector<uint32_t>& out) const {
+	constexpr float nearPlane = 0.05f;
+	constexpr uint32_t bucketCount = 65536;
+	constexpr uint32_t skipped = 0xFFFFFFFFu;
+
+	const size_t count = this->m_centers.size ();
+
+	scratch.depths.resize (count);
+	scratch.keys.resize (count);
+	scratch.counts.assign (bucketCount + 1, 0);
+
+	float nearest = std::numeric_limits<float>::max ();
+	float farthest = std::numeric_limits<float>::lowest ();
+
+	for (size_t i = 0; i < count; i++) {
+	    const float depth = glm::dot (this->m_centers[i] - position, forward);
+
+	    scratch.depths[i] = depth;
+
+	    if (depth > nearPlane) {
+		nearest = std::min (nearest, depth);
+		farthest = std::max (farthest, depth);
+	    }
+	}
+
+	out.clear ();
+
+	if (farthest < nearest) {
+	    return;
+	}
+
+	const float scale = static_cast<float> (bucketCount - 1) / std::max (farthest - nearest, 1e-6f);
+	size_t visible = 0;
+
+	for (size_t i = 0; i < count; i++) {
+	    if (scratch.depths[i] <= nearPlane) {
+		scratch.keys[i] = skipped;
+		continue;
+	    }
+
+	    // farthest first, so the largest depth lands in bucket 0
+	    const uint32_t key = static_cast<uint32_t> ((farthest - scratch.depths[i]) * scale);
+
+	    scratch.keys[i] = std::min (key, bucketCount - 1);
+	    scratch.counts[scratch.keys[i] + 1]++;
+	    visible++;
+	}
+
+	for (uint32_t bucket = 0; bucket < bucketCount; bucket++) {
+	    scratch.counts[bucket + 1] += scratch.counts[bucket];
+	}
+
+	out.resize (visible);
+
+	for (size_t i = 0; i < count; i++) {
+	    if (scratch.keys[i] != skipped) {
+		out[scratch.counts[scratch.keys[i]]++] = this->m_ids[i];
+	    }
+	}
+    }
+
+    void run () {
+	Scratch scratch;
+	std::unique_lock lock (this->m_mutex);
+
+	while (true) {
+	    this->m_wake.wait (lock, [this] { return this->m_quit || this->m_hasRequest; });
+
+	    if (this->m_quit) {
+		return;
+	    }
+
+	    const glm::vec3 position = this->m_requestPosition;
+	    const glm::vec3 forward = this->m_requestForward;
+	    this->m_hasRequest = false;
+
+	    lock.unlock ();
+
+	    std::vector<uint32_t> result;
+	    this->sort (position, forward, scratch, result);
+
+	    lock.lock ();
+	    this->m_result = std::move (result);
+	    this->m_hasResult = true;
+	}
+    }
+
+    const std::vector<glm::vec3> m_centers;
+    const std::vector<uint32_t> m_ids;
+
+    std::mutex m_mutex;
+    std::condition_variable m_wake;
+    glm::vec3 m_requestPosition = {};
+    glm::vec3 m_requestForward = {};
+    bool m_hasRequest = false;
+    std::vector<uint32_t> m_result;
+    bool m_hasResult = false;
+    bool m_quit = false;
+    std::thread m_worker;
+};
+} // namespace WallpaperEngine::Render::Wallpapers
+
+namespace {
+// Gaussian splat rasterizer: camera facing quad instances, the screen space ellipse comes from the projected 3D covariance (EWA splatting).
+const char* SPLAT_VERTEX_SHADER = R"(#version 330
+precision highp float;
+
+uniform usampler2D u_Centers;
+uniform sampler2D u_Rotations;
+uniform sampler2D u_Scales;
+uniform mat4 u_View;
+uniform mat4 u_Projection;
+uniform vec2 u_Viewport;
+uniform vec2 u_Focal;
+uniform int u_TextureWidth;
+
+// The clock: a seven segment display in screen space. Splats inside a digit segment are inverted and float up,
+// the splats where the digits end up are faded out so they read against the background.
+uniform vec4 u_ClockPoints[32];
+uniform vec4 u_ClockParams;
+uniform vec4 u_ClockBounds;
+uniform float u_ClockDistance;
+
+in vec2 a_Corner;
+in uint a_Index;
+
+out vec4 v_Color;
+out vec2 v_Corner;
+
+float clockField (vec2 screen, vec2 maskOffset) {
+    vec2 toCircle = vec2 (max (1.0, u_ClockParams.w), 1.0);
+    float radius = u_ClockParams.z;
+    float field = 0.0;
+
+    for (int i = 0; i < 32; i++) {
+        vec4 point = u_ClockPoints[i];
+
+        if (abs (point.z) <= 0.001) {
+            continue;
+        }
+
+        vec2 delta = (screen - (point.xy + maskOffset)) * toCircle;
+        float halfLength = abs (point.w);
+        float dist = length (delta);
+
+        if (halfLength > 0.0001) {
+            dist = point.w >= 0.0
+                ? length (vec2 (max (abs (delta.x) - halfLength, 0.0), delta.y))
+                : length (vec2 (delta.x, max (abs (delta.y) - halfLength, 0.0)));
+        }
+
+        float influence = dist <= radius ? point.z : 0.0;
+
+        if (abs (influence) > abs (field)) {
+            field = influence;
+        }
+    }
+
+    return field;
+}
+
+void main () {
+    ivec2 texel = ivec2 (int (a_Index) % u_TextureWidth, int (a_Index) / u_TextureWidth);
+    uvec4 packedCenter = texelFetch (u_Centers, texel, 0);
+    vec3 center = uintBitsToFloat (packedCenter.xyz);
+
+    vec4 camera = u_View * vec4 (center, 1.0);
+    float depth = -camera.z;
+    vec4 clip = u_Projection * camera;
+
+    if (depth < 0.05 || abs (clip.x) > clip.w * 1.3 || abs (clip.y) > clip.w * 1.3) {
+        gl_Position = vec4 (0.0, 0.0, 2.0, 1.0);
+        v_Color = vec4 (0.0);
+        v_Corner = vec2 (0.0);
+        return;
+    }
+
+    vec4 q = texelFetch (u_Rotations, texel, 0);
+    vec3 s = texelFetch (u_Scales, texel, 0).xyz;
+
+    float x = q.x, y = q.y, z = q.z, w = q.w;
+    mat3 rotation = mat3 (
+        1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y),
+        2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x),
+        2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y)
+    );
+
+    mat3 axes = mat3 (u_View) * mat3 (rotation[0] * s.x, rotation[1] * s.y, rotation[2] * s.z);
+    mat3 covariance = axes * transpose (axes);
+
+    float invDepth = 1.0 / depth;
+    vec3 jacobianX = vec3 (u_Focal.x * invDepth, 0.0, u_Focal.x * camera.x * invDepth * invDepth);
+    vec3 jacobianY = vec3 (0.0, u_Focal.y * invDepth, u_Focal.y * camera.y * invDepth * invDepth);
+
+    vec3 covarianceX = covariance * jacobianX;
+    vec3 covarianceY = covariance * jacobianY;
+
+    // the 0.3 is the usual low pass filter that keeps sub-pixel splats from vanishing
+    float a = dot (jacobianX, covarianceX) + 0.3;
+    float b = dot (jacobianX, covarianceY);
+    float c = dot (jacobianY, covarianceY) + 0.3;
+
+    float mid = 0.5 * (a + c);
+    float radius = length (vec2 (0.5 * (a - c), b));
+    float lambda1 = mid + radius;
+    float lambda2 = max (mid - radius, 0.1);
+
+    vec2 direction = abs (b) > 1e-6 ? normalize (vec2 (b, lambda1 - a)) : (a >= c ? vec2 (1.0, 0.0) : vec2 (0.0, 1.0));
+    vec2 major = min (sqrt (2.0 * lambda1), 1024.0) * direction;
+    vec2 minor = min (sqrt (2.0 * lambda2), 1024.0) * vec2 (direction.y, -direction.x);
+
+    vec2 offset = a_Corner.x * major + a_Corner.y * minor;
+
+    vec2 centerNdc = clip.xy / clip.w;
+    vec2 ndc = centerNdc + offset * 2.0 / u_Viewport;
+
+    uint rgba = packedCenter.w;
+    v_Color = vec4 (float (rgba & 255u), float ((rgba >> 8) & 255u), float ((rgba >> 16) & 255u), float (rgba >> 24)) / 255.0;
+
+    vec2 screen = vec2 (centerNdc.x * 0.5 + 0.5, 0.5 - centerNdc.y * 0.5);
+
+    if (u_ClockParams.x > 0.0 && screen.x >= u_ClockBounds.x && screen.y >= u_ClockBounds.y
+        && screen.x <= u_ClockBounds.z && screen.y <= u_ClockBounds.w) {
+        float radius = u_ClockParams.z;
+        float lift = u_ClockParams.y;
+        float influence = clockField (screen, vec2 (0.0));
+
+        if (influence > 0.001) {
+            v_Color.rgb = 1.0 - v_Color.rgb;
+        } else {
+            float destination = max (0.0, clockField (screen, vec2 (0.0, -radius * (0.58 + 2.35) * lift)));
+
+            v_Color.a *= 1.0 - clamp (u_ClockDistance * destination * 0.72, 0.0, 0.96);
+        }
+
+        if (abs (influence) > 0.001) {
+            float animation = abs (influence);
+            float seed = fract (sin ((float (a_Index) * 29.29 + 7.7) * 12.9898) * 43758.5453);
+            float liftPhase = smoothstep (0.0, 0.48, animation);
+            float risePhase = smoothstep (0.34, 1.0, animation);
+            float verticalSign = influence >= 0.0 ? -1.0 : 1.0;
+            vec2 screenOffset = vec2 (
+                (seed - 0.5) * radius * 0.10, verticalSign * radius * (0.58 * liftPhase + 2.35 * risePhase)
+            ) * lift;
+
+            // screen space is y down, clip space y up
+            ndc += vec2 (screenOffset.x * 2.0, -screenOffset.y * 2.0);
+        }
+    }
+
+    // the engine samples wallpaper textures with row 0 at the top, but a GL render target stores row
+    // 0 at the bottom, so the whole frame is drawn upside down here
+    gl_Position = vec4 (ndc.x, -ndc.y, clip.z / clip.w, 1.0);
+    v_Corner = a_Corner;
+}
+)";
+
+const char* SPLAT_FRAGMENT_SHADER = R"(#version 330
+precision highp float;
+
+in vec4 v_Color;
+in vec2 v_Corner;
+
+out vec4 out_FragColor;
+
+void main () {
+    float falloff = -dot (v_Corner, v_Corner);
+
+    if (falloff < -4.0) {
+        discard;
+    }
+
+    float alpha = exp (falloff) * v_Color.a;
+
+    if (alpha < 1.0 / 255.0) {
+        discard;
+    }
+
+    out_FragColor = vec4 (v_Color.rgb, alpha);
+}
+)";
+
+GLuint compileShader (GLenum type, const char* source) {
+    const GLuint shader = glCreateShader (type);
+
+    glShaderSource (shader, 1, &source, nullptr);
+    glCompileShader (shader);
+
+    GLint status = GL_FALSE;
+    glGetShaderiv (shader, GL_COMPILE_STATUS, &status);
+
+    if (status != GL_TRUE) {
+	GLint length = 0;
+	glGetShaderiv (shader, GL_INFO_LOG_LENGTH, &length);
+	std::string log (std::max (length, 1), '\0');
+	glGetShaderInfoLog (shader, length, nullptr, log.data ());
+	glDeleteShader (shader);
+	sLog.exception ("Cannot compile the splat shader: ", log);
+    }
+
+    return shader;
+}
+
+float quantile (const std::vector<float>& sorted, float fraction) {
+    const auto index = static_cast<long> (std::lround ((sorted.size () - 1) * fraction));
+
+    return sorted[std::clamp<long> (index, 0, static_cast<long> (sorted.size ()) - 1)];
+}
+
+// Horizontal/vertical extent of the frustum at unit distance, given the wallpaper camera's fov
+// (horizontal for landscape viewports, vertical for portrait, matching the viewer)
+glm::vec2 frustumTangents (float fovDegrees, int width, int height) {
+    const float w = static_cast<float> (std::max (width, 1));
+    const float h = static_cast<float> (std::max (height, 1));
+    const float aspect = w / h;
+    const float tangent = std::tan (std::clamp (fovDegrees, 1.0f, 179.0f) * glm::pi<float> () / 360.0f);
+
+    return w > h ? glm::vec2 (tangent, tangent / aspect) : glm::vec2 (tangent * aspect, tangent);
+}
+} // namespace
+
+bool CSplat::supports (const Project& project) {
+    const auto& properties = project.properties;
+
+    return properties.contains ("sogPreset") && properties.contains ("sogmeta") && properties.contains ("sogdirectory");
+}
+
+CSplat::CSplat (
+    const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
+    const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
+) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
+    this->setupFramebuffers ();
+    this->loadCloud ();
+}
+
+CSplat::~CSplat () {
+    // stops the worker before the buffers it never touches are freed
+    this->m_sorter.reset ();
+
+    glDeleteProgram (this->m_program);
+    glDeleteVertexArrays (1, &this->m_vao);
+    glDeleteBuffers (1, &this->m_cornerBuffer);
+    glDeleteBuffers (1, &this->m_orderBuffer);
+    glDeleteTextures (1, &this->m_centerTexture);
+    glDeleteTextures (1, &this->m_rotationTexture);
+    glDeleteTextures (1, &this->m_scaleTexture);
+}
+
+double CSplat::numberProperty (const std::string& name, double fallback) const {
+    const auto& properties = this->getWallpaperData ().project.properties;
+    const auto property = properties.find (name);
+
+    if (property == properties.end ()) {
+	return fallback;
+    }
+
+    const std::string text = property->second->toString ();
+
+    if (text == "true") {
+	return 1.0;
+    }
+
+    if (text == "false") {
+	return 0.0;
+    }
+
+    try {
+	return std::stod (text);
+    } catch (const std::exception&) {
+	return fallback;
+    }
+}
+
+std::string CSplat::stringProperty (const std::string& name) const {
+    const auto& properties = this->getWallpaperData ().project.properties;
+    const auto property = properties.find (name);
+
+    return property == properties.end () ? std::string () : property->second->toString ();
+}
+
+void CSplat::loadCloud () {
+    const auto& locator = *this->getWallpaperData ().project.assetLocator;
+
+    // same source selection as the wallpaper's own page: a workshop preset carries its own splat
+    // data (inline meta + a directory of images), everything else uses one of the bundled scenes
+    std::string meta = this->stringProperty ("sogmeta");
+    std::string directory = this->stringProperty ("sogdirectory");
+
+    std::replace (directory.begin (), directory.end (), '\\', '/');
+
+    while (!directory.empty () && directory.back () == '/') {
+	directory.pop_back ();
+    }
+
+    this->m_containFraming = !meta.empty () && !directory.empty ();
+
+    if (meta.empty () || directory.empty ()) {
+	const int preset = static_cast<int> (this->numberProperty ("sogPreset", 1));
+
+	if (preset != 1 && preset != 2) {
+	    sLog.exception ("This SOG wallpaper uses a custom splat file, which the native renderer cannot load");
+	}
+
+	directory = "imgs/" + std::to_string (preset);
+	meta = locator.readString (directory + "/meta.json");
+    }
+
+    Splat::SplatCloud cloud;
+
+    try {
+	cloud = Splat::loadSog (meta, [&] (const std::string& name) { return locator.readString (directory + "/" + name); });
+    } catch (const std::exception& e) {
+	sLog.exception ("Cannot load the splat cloud from ", directory, ": ", e.what ());
+    }
+
+    this->m_cloudCount = cloud.count;
+    this->m_textureWidth = cloud.textureWidth;
+
+    // The viewer hangs the splats off an entity rotated 180 degrees around Z (the photo frame has y
+    // pointing down), so its camera math all happens in that frame: (x, y, z) -> (-x, -y, z).
+    std::vector<glm::vec3> worldCenters;
+    std::vector<uint32_t> drawIds;
+    worldCenters.reserve (cloud.count);
+    drawIds.reserve (cloud.count);
+
+    std::vector<float> horizontalAngles;
+    std::vector<float> verticalAngles;
+    std::vector<float> depths;
+
+    const uint32_t step = std::max (1u, (cloud.count + 119999) / 120000);
+    glm::vec3 boundsMin (std::numeric_limits<float>::max ());
+    glm::vec3 boundsMax (std::numeric_limits<float>::lowest ());
+
+    for (uint32_t i = 0; i < cloud.count; i++) {
+	const float* centerAndColor = &cloud.centerAndColor[static_cast<size_t> (i) * 4];
+	const glm::vec3 world (-centerAndColor[0], -centerAndColor[1], centerAndColor[2]);
+
+	boundsMin = glm::min (boundsMin, world);
+	boundsMax = glm::max (boundsMax, world);
+
+	uint32_t packedColor;
+	std::memcpy (&packedColor, &centerAndColor[3], sizeof (packedColor));
+
+	// fully transparent splats never contribute a pixel
+	if ((packedColor >> 24) != 0) {
+	    worldCenters.push_back (world);
+	    drawIds.push_back (i);
+	}
+
+	if (i % step == 0 && world.z > 1e-4f) {
+	    horizontalAngles.push_back (std::atan2 (world.x, world.z));
+	    verticalAngles.push_back (std::atan2 (world.y, world.z));
+	    depths.push_back (world.z);
+	}
+    }
+
+    this->m_profile.boundsCenter = (boundsMin + boundsMax) * 0.5f;
+    this->m_profile.boundsHalfExtents = glm::max ((boundsMax - boundsMin) * 0.5f, glm::vec3 (0.001f));
+
+    if (depths.size () >= 128) {
+	std::sort (horizontalAngles.begin (), horizontalAngles.end ());
+	std::sort (verticalAngles.begin (), verticalAngles.end ());
+	std::sort (depths.begin (), depths.end ());
+
+	const float horizontalMin = quantile (horizontalAngles, 0.02f);
+	const float horizontalMax = quantile (horizontalAngles, 0.98f);
+	const float verticalMin = quantile (verticalAngles, 0.02f);
+	const float verticalMax = quantile (verticalAngles, 0.98f);
+
+	this->m_profile.direction = glm::normalize (glm::vec3 (
+	    std::tan ((horizontalMin + horizontalMax) * 0.5f), std::tan ((verticalMin + verticalMax) * 0.5f), 1.0f
+	));
+	this->m_profile.angularSize
+	    = { std::max (horizontalMax - horizontalMin, 0.01f), std::max (verticalMax - verticalMin, 0.01f) };
+	this->m_profile.depthMin = quantile (depths, 0.02f);
+	this->m_profile.depthMax = std::max (quantile (depths, 0.98f), this->m_profile.depthMin + 0.01f);
+    } else {
+	sLog.error ("SOG wallpaper has too few splats in front of the camera to derive its framing, using defaults");
+    }
+
+    sLog.out (
+	"Loaded ", cloud.count, " splats (", drawIds.size (), " visible), depth ", this->m_profile.depthMin, " - ",
+	this->m_profile.depthMax
+    );
+
+    this->setupGL (cloud.centerAndColor, cloud.rotation, cloud.scale);
+    this->m_sorter = std::make_unique<SplatSorter> (std::move (worldCenters), std::move (drawIds));
+}
+
+void CSplat::setupGL (
+    const std::vector<float>& centers, const std::vector<float>& rotations, const std::vector<float>& scales
+) {
+    const GLsizei width = static_cast<GLsizei> (this->m_textureWidth);
+    const GLsizei height = static_cast<GLsizei> (centers.size () / 4 / this->m_textureWidth);
+
+    const auto makeTexture = [] (GLuint& texture) {
+	glGenTextures (1, &texture);
+	glBindTexture (GL_TEXTURE_2D, texture);
+	// integer textures cannot be filtered, and the shader only ever texelFetch()es anyway
+	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);
+    };
+
+    // integer texture, a float one may flush denormals and eat some colors
+    makeTexture (this->m_centerTexture);
+    glTexImage2D (
+	GL_TEXTURE_2D, 0, GL_RGBA32UI, width, height, 0, GL_RGBA_INTEGER, GL_UNSIGNED_INT, centers.data ()
+    );
+
+    makeTexture (this->m_rotationTexture);
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, GL_FLOAT, rotations.data ());
+
+    makeTexture (this->m_scaleTexture);
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, GL_FLOAT, scales.data ());
+
+    glBindTexture (GL_TEXTURE_2D, 0);
+
+    const GLuint vertexShader = compileShader (GL_VERTEX_SHADER, SPLAT_VERTEX_SHADER);
+    const GLuint fragmentShader = compileShader (GL_FRAGMENT_SHADER, SPLAT_FRAGMENT_SHADER);
+
+    this->m_program = glCreateProgram ();
+    glAttachShader (this->m_program, vertexShader);
+    glAttachShader (this->m_program, fragmentShader);
+    glLinkProgram (this->m_program);
+
+    GLint linked = GL_FALSE;
+    glGetProgramiv (this->m_program, GL_LINK_STATUS, &linked);
+
+    if (linked != GL_TRUE) {
+	GLint length = 0;
+	glGetProgramiv (this->m_program, GL_INFO_LOG_LENGTH, &length);
+	std::string log (std::max (length, 1), '\0');
+	glGetProgramInfoLog (this->m_program, length, nullptr, log.data ());
+	sLog.exception ("Cannot link the splat shader: ", log);
+    }
+
+    glDetachShader (this->m_program, vertexShader);
+    glDetachShader (this->m_program, fragmentShader);
+    glDeleteShader (vertexShader);
+    glDeleteShader (fragmentShader);
+
+    this->u_Centers = glGetUniformLocation (this->m_program, "u_Centers");
+    this->u_Rotations = glGetUniformLocation (this->m_program, "u_Rotations");
+    this->u_Scales = glGetUniformLocation (this->m_program, "u_Scales");
+    this->u_View = glGetUniformLocation (this->m_program, "u_View");
+    this->u_Projection = glGetUniformLocation (this->m_program, "u_Projection");
+    this->u_Viewport = glGetUniformLocation (this->m_program, "u_Viewport");
+    this->u_Focal = glGetUniformLocation (this->m_program, "u_Focal");
+    this->u_TextureWidth = glGetUniformLocation (this->m_program, "u_TextureWidth");
+    this->u_ClockPoints = glGetUniformLocation (this->m_program, "u_ClockPoints");
+    this->u_ClockParams = glGetUniformLocation (this->m_program, "u_ClockParams");
+    this->u_ClockBounds = glGetUniformLocation (this->m_program, "u_ClockBounds");
+    this->u_ClockDistance = glGetUniformLocation (this->m_program, "u_ClockDistance");
+
+    const GLint cornerAttribute = glGetAttribLocation (this->m_program, "a_Corner");
+    const GLint indexAttribute = glGetAttribLocation (this->m_program, "a_Index");
+
+    constexpr GLfloat corners[] = { -2.0f, -2.0f, 2.0f, -2.0f, -2.0f, 2.0f, 2.0f, 2.0f };
+
+    glGenVertexArrays (1, &this->m_vao);
+    glBindVertexArray (this->m_vao);
+
+    glGenBuffers (1, &this->m_cornerBuffer);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_cornerBuffer);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (corners), corners, GL_STATIC_DRAW);
+    glEnableVertexAttribArray (cornerAttribute);
+    glVertexAttribPointer (cornerAttribute, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
+
+    glGenBuffers (1, &this->m_orderBuffer);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_orderBuffer);
+    glEnableVertexAttribArray (indexAttribute);
+    glVertexAttribIPointer (indexAttribute, 1, GL_UNSIGNED_INT, 0, nullptr);
+    glVertexAttribDivisor (indexAttribute, 1);
+
+    glBindVertexArray (GL_NONE);
+    glBindBuffer (GL_ARRAY_BUFFER, GL_NONE);
+}
+
+void CSplat::resizeOutput (int width, int height) {
+    this->m_width = width;
+    this->m_height = height;
+    this->m_hasBase = false;
+    this->m_hasDrawn = false;
+
+    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    glBindTexture (GL_TEXTURE_2D, 0);
+}
+
+std::vector<CSplat::ClockPoint> CSplat::buildClockPoints (
+    const std::string& text, float aspect, float size, float originXPercent, float originYPercent
+) const {
+    // seven segment layout in a digit's own units: segments a-g as (x1, y1, x2, y2)
+    static constexpr float SEGMENT_LINES[7][4] = {
+	{ 0.18f, 0.00f, 0.82f, 0.00f }, { 1.00f, 0.14f, 1.00f, 0.68f }, { 1.00f, 0.92f, 1.00f, 1.46f },
+	{ 0.18f, 1.60f, 0.82f, 1.60f }, { 0.00f, 0.92f, 0.00f, 1.46f }, { 0.00f, 0.14f, 0.00f, 0.68f },
+	{ 0.18f, 0.80f, 0.82f, 0.80f },
+    };
+    static constexpr const char* DIGIT_SEGMENTS[10]
+	= { "abcdef", "bc", "abged", "abgcd", "fgbc", "afgcd", "afgecd", "abc", "abcdefg", "abcdfg" };
+    constexpr float DIGIT_HEIGHT = 1.6f;
+    constexpr float DIGIT_ADVANCE = 1.32f;
+    constexpr float COLON_WIDTH = 0.38f;
+    constexpr float CLOCK_WIDTH = 0.36f;
+
+    struct Raw {
+	float x;
+	float y;
+	bool horizontal;
+	float logicalHalfLength;
+    };
+
+    std::vector<Raw> raw;
+    float cursor = 0.0f;
+
+    for (const char character : text) {
+	if (character == ':') {
+	    raw.push_back ({ cursor + COLON_WIDTH * 0.5f, 0.54f, false, 0.0f });
+	    raw.push_back ({ cursor + COLON_WIDTH * 0.5f, 1.06f, false, 0.0f });
+	    cursor += COLON_WIDTH + 0.30f;
+	    continue;
+	}
+
+	if (character < '0' || character > '9') {
+	    continue;
+	}
+
+	for (const char* segment = DIGIT_SEGMENTS[character - '0']; *segment != '\0'; segment++) {
+	    const float* line = SEGMENT_LINES[*segment - 'a'];
+	    const float dx = std::abs (line[2] - line[0]);
+	    const float dy = std::abs (line[3] - line[1]);
+
+	    raw.push_back (
+		{ cursor + (line[0] + line[2]) * 0.5f, (line[1] + line[3]) * 0.5f, dx >= dy, std::max (dx, dy) * 0.5f }
+	    );
+	}
+
+	cursor += DIGIT_ADVANCE;
+    }
+
+    const float totalWidth = std::max (cursor - 0.16f, 1.0f);
+    const float width = CLOCK_WIDTH * size;
+    const float height = width * (DIGIT_HEIGHT / totalWidth) * aspect;
+    const float originX = std::clamp (originXPercent / 100.0f, 0.02f, 0.98f);
+    const float originY = std::clamp (originYPercent / 100.0f, 0.02f, 0.98f);
+
+    std::vector<ClockPoint> points;
+
+    for (size_t i = 0; i < raw.size () && i < CLOCK_POINT_CAPACITY; i++) {
+	const Raw& point = raw[i];
+	ClockPoint result;
+
+	result.x = std::clamp (originX + (point.x / totalWidth - 0.5f) * width, 0.0f, 1.0f);
+	result.y = std::clamp (originY + (point.y / DIGIT_HEIGHT - 0.5f) * height, 0.0f, 1.0f);
+
+	if (point.logicalHalfLength > 0.0f) {
+	    result.halfLength = point.horizontal ? point.logicalHalfLength / totalWidth * width * aspect
+						 : -point.logicalHalfLength / DIGIT_HEIGHT * height;
+	}
+
+	result.strength = 1.0f;
+	points.push_back (result);
+    }
+
+    return points;
+}
+
+bool CSplat::updateClock (float aspect) {
+    constexpr float TRANSITION_SECONDS = 0.82f;
+    constexpr float BASE_RADIUS = 0.011f;
+
+    const bool enabled = this->numberProperty ("clockEnabled", 1.0) != 0.0;
+    const float size = std::clamp (static_cast<float> (this->numberProperty ("clockSize", 1.0)), 0.55f, 1.8f);
+    const float x = std::clamp (static_cast<float> (this->numberProperty ("clockX", 50.0)), 8.0f, 92.0f);
+    const float y = std::clamp (static_cast<float> (this->numberProperty ("clockY", 18.0)), 8.0f, 92.0f);
+    const float lift = std::clamp (static_cast<float> (this->numberProperty ("clockLift", 0.92)), 0.0f, 2.0f);
+    const float distance = std::clamp (static_cast<float> (this->numberProperty ("clockDistance", 0.92)), 0.0f, 2.0f);
+    const float radius = std::clamp (BASE_RADIUS * size, 0.008f, 0.055f);
+
+    const auto now = std::chrono::steady_clock::now ();
+    const std::array<float, 5> layout = { size, x, y, aspect, enabled ? 1.0f : 0.0f };
+
+    const std::time_t wallClock = std::time (nullptr);
+    std::tm local {};
+    localtime_r (&wallClock, &local);
+    char text[8];
+    std::snprintf (text, sizeof (text), "%02d:%02d", local.tm_hour, local.tm_min);
+
+    if (layout != this->m_clockLayout) {
+	// first frame, or the clock was moved/resized: rebuild it from nothing so it rises in again
+	this->m_clockLayout = layout;
+	this->m_clockText = text;
+	this->m_clockPrevious.clear ();
+	this->m_clockCurrent = this->buildClockPoints (this->m_clockText, aspect, size, x, y);
+	this->m_clockTransitionStart = now;
+    } else if (this->m_clockText != text || this->m_clockCurrent.empty ()) {
+	this->m_clockPrevious = this->m_clockCurrent;
+	this->m_clockText = text;
+	this->m_clockCurrent = this->buildClockPoints (this->m_clockText, aspect, size, x, y);
+	this->m_clockTransitionStart = now;
+    }
+
+    const float progress = std::clamp (std::chrono::duration<float> (now - this->m_clockTransitionStart).count () / TRANSITION_SECONDS, 0.0f, 1.0f);
+    const float rise = 1.0f - std::pow (1.0f - progress, 3.0f);
+    const float sink = std::pow (1.0f - progress, 3.0f);
+
+    // points that stay across a minute change keep full strength; new ones rise, old ones sink away
+    const auto keyOf = [] (const ClockPoint& point) {
+	return std::make_pair (std::lround (point.x * 10000.0f), std::lround (point.y * 10000.0f));
+    };
+    std::vector<std::pair<long, long>> currentKeys;
+    std::vector<std::pair<long, long>> previousKeys;
+
+    for (const auto& point : this->m_clockCurrent) {
+	currentKeys.push_back (keyOf (point));
+    }
+
+    for (const auto& point : this->m_clockPrevious) {
+	previousKeys.push_back (keyOf (point));
+    }
+
+    const auto contains = [] (const std::vector<std::pair<long, long>>& keys, const std::pair<long, long>& key) {
+	return std::find (keys.begin (), keys.end (), key) != keys.end ();
+    };
+
+    std::vector<ClockPoint> packed;
+
+    for (const auto& point : this->m_clockCurrent) {
+	ClockPoint entry = point;
+	entry.strength = contains (previousKeys, keyOf (point)) ? 1.0f : rise;
+	packed.push_back (entry);
+    }
+
+    if (progress < 1.0f) {
+	for (const auto& point : this->m_clockPrevious) {
+	    if (!contains (currentKeys, keyOf (point))) {
+		ClockPoint entry = point;
+		entry.strength = -sink;
+		packed.push_back (entry);
+	    }
+	}
+    }
+
+    std::stable_sort (packed.begin (), packed.end (), [] (const ClockPoint& a, const ClockPoint& b) {
+	return std::abs (a.strength) > std::abs (b.strength);
+    });
+
+    std::array<glm::vec4, CLOCK_POINT_CAPACITY> points = {};
+    bool active = false;
+    glm::vec4 bounds (1.0f, 1.0f, 0.0f, 0.0f);
+    // where the digits end up floating to: the splats behind that spot get faded out
+    const float finalRise = radius * (0.58f + 2.35f) * lift;
+
+    for (size_t i = 0; i < packed.size () && i < CLOCK_POINT_CAPACITY; i++) {
+	const ClockPoint& point = packed[i];
+	const float strength = enabled ? std::clamp (point.strength, -1.0f, 1.0f) : 0.0f;
+
+	points[i] = { point.x, point.y, strength, point.halfLength };
+
+	if (std::abs (strength) <= 0.001f) {
+	    continue;
+	}
+
+	active = true;
+
+	// reach of this capsule on screen (x is stretched by the aspect ratio when measuring distance)
+	const float halfLength = std::abs (point.halfLength);
+	const float reachX = ((point.halfLength > 0.0f ? halfLength : 0.0f) + radius) / std::max (1.0f, aspect);
+	const float reachY = (point.halfLength < 0.0f ? halfLength : 0.0f) + radius;
+
+	bounds.x = std::min (bounds.x, point.x - reachX);
+	bounds.y = std::min (bounds.y, point.y - reachY - finalRise);
+	bounds.z = std::max (bounds.z, point.x + reachX);
+	bounds.w = std::max (bounds.w, point.y + reachY);
+    }
+
+    const glm::vec4 params (active ? 1.0f : 0.0f, lift, radius, std::max (1.0f, aspect));
+    const bool changed = points != this->m_clockPoints || params != this->m_clockParams || distance != this->m_clockDistance;
+
+    this->m_clockPoints = points;
+    this->m_clockParams = params;
+    this->m_clockBounds = bounds;
+    this->m_clockDistance = distance;
+
+    return changed;
+}
+
+CSplat::BaseCamera CSplat::buildBaseCamera (int width, int height, float focusDepth) const {
+    const SceneProfile& profile = this->m_profile;
+    BaseCamera base;
+
+    // the viewer treats a focus depth of 1 or less as "unset" and falls back to 30%
+    const float focusPercent = std::clamp (focusDepth <= 1.0f ? 30.0f : focusDepth, 0.0f, 100.0f) / 100.0f;
+    const float focusOnAxis = profile.depthMin + (profile.depthMax - profile.depthMin) * focusPercent;
+    const float focusDistance = std::max (0.01f, focusOnAxis / std::max (profile.direction.z, 1e-4f));
+
+    base.position = glm::vec3 (0.0f);
+    base.target = profile.direction * focusDistance;
+    base.distance = focusDistance;
+    base.front = glm::normalize (base.position - base.target);
+    base.right = glm::normalize (glm::cross (glm::vec3 (0.0f, 1.0f, 0.0f), profile.direction));
+    base.up = glm::normalize (glm::cross (profile.direction, base.right));
+
+    const float inverseFocus = 1.0f / std::max (focusOnAxis, 1e-4f);
+    base.parallaxDepthFactor = std::max (
+	std::abs (1.0f / std::max (profile.depthMin, 1e-4f) - inverseFocus),
+	std::abs (1.0f / std::max (profile.depthMax, 1e-4f) - inverseFocus)
+    );
+
+    const float cameraSide = base.front.z < 0.0f ? -1.0f : 1.0f;
+    base.frontZ = profile.boundsCenter.z + cameraSide * profile.boundsHalfExtents.z;
+
+    // Fit the photo's angular size to the viewport. The base page crops to fill (the smaller of the
+    // two fits); presets show the whole photo (the larger). Both keep the viewer's 4% breathing room.
+    const float aspect = static_cast<float> (width) / static_cast<float> (std::max (height, 1));
+    const float horizontal = profile.angularSize.x;
+    const float vertical = profile.angularSize.y;
+    const float horizontalFit = width > height ? horizontal : 2.0f * std::atan (std::tan (horizontal * 0.5f) / aspect);
+    const float verticalFit = width > height ? 2.0f * std::atan (std::tan (vertical * 0.5f) * aspect) : vertical;
+    const float fov = this->m_containFraming ? std::max (horizontalFit, verticalFit) : std::min (horizontalFit, verticalFit);
+
+    base.fov = std::clamp (glm::degrees (fov) * 1.04f, 20.0f, 120.0f);
+    base.viewport = { width, height };
+    base.focusDepth = focusDepth;
+
+    return base;
+}
+
+bool CSplat::viewFitsFrontFace (const glm::vec3& position, const glm::vec3& target, const BaseCamera& base) const {
+    const glm::vec3 toTarget = target - position;
+
+    if (glm::length (toTarget) <= 1e-6f) {
+	return false;
+    }
+
+    const glm::vec3 forward = glm::normalize (toTarget);
+    const glm::vec3 sideways (forward.z, 0.0f, -forward.x);
+
+    if (glm::length (sideways) <= 1e-6f) {
+	return false;
+    }
+
+    const glm::vec3 right = glm::normalize (sideways);
+    const glm::vec3 up = glm::normalize (glm::cross (forward, right));
+    const glm::vec2 tangents = frustumTangents (base.fov, base.viewport.x, base.viewport.y);
+    const glm::vec3& center = this->m_profile.boundsCenter;
+    const glm::vec3& half = this->m_profile.boundsHalfExtents;
+    const float epsilon = std::max (half.x, half.y) * 0.0001f;
+
+    for (const float xSign : { -1.0f, 1.0f }) {
+	for (const float ySign : { -1.0f, 1.0f }) {
+	    const glm::vec3 ray = forward + right * tangents.x * xSign + up * tangents.y * ySign;
+
+	    if (std::abs (ray.z) <= 1e-6f) {
+		return false;
+	    }
+
+	    const float distance = (base.frontZ - position.z) / ray.z;
+
+	    if (!std::isfinite (distance) || distance <= 0.0f) {
+		return false;
+	    }
+
+	    const glm::vec3 hit = position + ray * distance;
+
+	    if (hit.x < center.x - half.x - epsilon || hit.x > center.x + half.x + epsilon
+		|| hit.y < center.y - half.y - epsilon || hit.y > center.y + half.y + epsilon) {
+		return false;
+	    }
+	}
+    }
+
+    return true;
+}
+
+// The viewer's camera: sits at the photo's origin and moves on a sphere around the focus point,
+// as far as the mouse asks without the view leaving the front face of the splat cloud.
+CSplat::Pose CSplat::posePlacement (
+    const BaseCamera& base, glm::vec2 tilt, glm::vec2 orbit, float parallaxStrength
+) const {
+    const float strength = std::clamp (parallaxStrength, 0.0f, 0.16f);
+    float parallaxMove = base.distance * strength * 2.4f;
+
+    if (std::isfinite (base.parallaxDepthFactor) && base.parallaxDepthFactor > 1e-6f) {
+	const glm::vec2 tangents = frustumTangents (base.fov, base.viewport.x, base.viewport.y);
+	const float screenTangent = std::max (0.001f, std::min (tangents.x, tangents.y));
+	const float move = strength * 2.0f * screenTangent / base.parallaxDepthFactor;
+
+	if (std::isfinite (move) && move > 0.0f) {
+	    parallaxMove = std::min (move, base.distance * 0.8f);
+	}
+    }
+
+    const float orbitMove = std::min (std::max (base.distance, 1e-4f) * 0.18f, base.distance * 0.8f);
+
+    const glm::vec3 mouseOffset = base.right * (tilt.x * parallaxMove) + base.up * (-tilt.y * parallaxMove);
+    const glm::vec3 orbitOffset = base.right * (orbit.x * orbitMove) + base.up * (-orbit.y * orbitMove);
+
+    const auto fits = [&] (const glm::vec3& offset) -> std::optional<glm::vec3> {
+	const float tangentDistance = glm::length (offset);
+
+	if (tangentDistance >= base.distance) {
+	    return std::nullopt;
+	}
+
+	const float frontDistance = std::sqrt (std::max (base.distance * base.distance - tangentDistance * tangentDistance, 0.0f));
+	const glm::vec3 position = base.target + base.front * frontDistance + offset;
+
+	if (!this->viewFitsFrontFace (position, base.target, base)) {
+	    return std::nullopt;
+	}
+
+	return position;
+    };
+
+    // shrink an offset toward zero until the view fits, 14 halvings like the viewer
+    const auto shrink = [&] (const glm::vec3& fixed, const glm::vec3& variable, glm::vec3 position) {
+	float low = 0.0f;
+	float high = 1.0f;
+
+	for (int iteration = 0; iteration < 14; iteration++) {
+	    const float middle = (low + high) * 0.5f;
+
+	    if (const auto candidate = fits (fixed + variable * middle)) {
+		low = middle;
+		position = *candidate;
+	    } else {
+		high = middle;
+	    }
+	}
+
+	return position;
+    };
+
+    Pose pose;
+    pose.target = base.target;
+
+    if (const auto full = fits (orbitOffset + mouseOffset)) {
+	pose.position = *full;
+    } else if (const auto orbitOnly = fits (orbitOffset)) {
+	pose.position = shrink (orbitOffset, mouseOffset, *orbitOnly);
+    } else {
+	pose.position = base.position;
+
+	if (this->viewFitsFrontFace (pose.position, base.target, base)) {
+	    pose.position = shrink (glm::vec3 (0.0f), orbitOffset, pose.position);
+	}
+    }
+
+    return pose;
+}
+
+void CSplat::renderFrame (const glm::ivec4& viewport) {
+    if (viewport.z <= 0 || viewport.w <= 0) {
+	return;
+    }
+
+    if (viewport.z != this->m_width || viewport.w != this->m_height) {
+	this->resizeOutput (viewport.z, viewport.w);
+    }
+
+    const bool depthEnabled = this->numberProperty ("depthEnabled", 1.0) != 0.0;
+    const float focusDepth = static_cast<float> (this->numberProperty ("focusDepth", 30.0));
+
+    if (!this->m_hasBase || this->m_base.viewport != glm::ivec2 (this->m_width, this->m_height)
+	|| this->m_base.focusDepth != focusDepth) {
+	this->m_base = this->buildBaseCamera (this->m_width, this->m_height, focusDepth);
+	this->m_hasBase = true;
+    }
+
+    const auto now = std::chrono::steady_clock::now ();
+    const float elapsed = this->m_hasLastFrame ? std::chrono::duration<float> (now - this->m_lastFrame).count () : 0.0f;
+    const float delta = std::clamp (elapsed, 0.0f, 0.05f);
+
+    this->m_lastFrame = now;
+    this->m_hasLastFrame = true;
+
+    // mouse -> target tilt, -1..1 with y up. Skipped for --disable-parallax, and until the wallpaper's
+    // depth effect is switched on.
+    glm::vec2 targetTilt (0.0f);
+    const auto& settings = this->getContext ().getApp ().getContext ().settings;
+
+    if (depthEnabled && !settings.mouse.disableparallax) {
+	const glm::dvec2 mouse = this->getContext ().getInputContext ().getMouseInput ().position ();
+	const float sensitivity = static_cast<float> (this->numberProperty ("sensorSensitivity", 4.0)) / 4.0f;
+	const float normalizedX = std::clamp (static_cast<float> ((mouse.x - viewport.x) / viewport.z), 0.0f, 1.0f);
+	const float normalizedY = std::clamp (static_cast<float> ((mouse.y - viewport.y) / viewport.w), 0.0f, 1.0f);
+
+	targetTilt = glm::clamp (glm::vec2 (normalizedX * 2.0f - 1.0f, normalizedY * 2.0f - 1.0f) * sensitivity, -1.0f, 1.0f);
+    }
+
+    // the viewer eases toward the target by 14% per 60Hz frame
+    const float inertia = 1.0f - std::pow (1.0f - 0.14f, delta * 60.0f);
+
+    this->m_tilt += (targetTilt - this->m_tilt) * inertia;
+
+    for (int axis = 0; axis < 2; axis++) {
+	if (std::abs (targetTilt[axis] - this->m_tilt[axis]) < 0.0005f) {
+	    this->m_tilt[axis] = targetTilt[axis];
+	}
+    }
+
+    glm::vec2 tilt = this->m_tilt;
+    const float tiltLength = glm::length (tilt);
+
+    if (tiltLength > 0.55f) {
+	tilt *= 0.55f / tiltLength;
+    }
+
+    glm::vec2 orbit (0.0f);
+
+    if (depthEnabled && this->numberProperty ("orbitEnabled", 0.0) != 0.0) {
+	const float speed = static_cast<float> (this->numberProperty ("orbitSpeed", 0.12));
+	const float amount = static_cast<float> (this->numberProperty ("orbitAmount", 0.34));
+
+	this->m_orbitPhase = std::fmod (this->m_orbitPhase + delta * speed * glm::two_pi<float> (), glm::two_pi<float> ());
+	orbit = { std::cos (this->m_orbitPhase) * amount, std::sin (this->m_orbitPhase) * amount * 0.47f };
+    }
+
+    const float parallaxStrength = static_cast<float> (this->numberProperty ("parallaxStrength", 0.08));
+    const Pose pose = this->posePlacement (this->m_base, tilt, orbit, parallaxStrength);
+
+    const glm::vec3 forward = glm::normalize (pose.target - pose.position);
+
+    // The splat data stays in the photo's frame, so the world flip the viewer applies to its entity
+    // goes into the view matrix instead
+    const glm::mat4 flip = glm::scale (glm::mat4 (1.0f), glm::vec3 (-1.0f, -1.0f, 1.0f));
+    const glm::mat4 view = glm::lookAt (pose.position, pose.target, glm::vec3 (0.0f, 1.0f, 0.0f)) * flip;
+
+    const glm::vec2 tangents = frustumTangents (this->m_base.fov, this->m_width, this->m_height);
+    constexpr float nearPlane = 0.05f;
+    constexpr float farPlane = 200.0f;
+    glm::mat4 projection (0.0f);
+    projection[0][0] = 1.0f / tangents.x;
+    projection[1][1] = 1.0f / tangents.y;
+    projection[2][2] = -(farPlane + nearPlane) / (farPlane - nearPlane);
+    projection[2][3] = -1.0f;
+    projection[3][2] = -2.0f * farPlane * nearPlane / (farPlane - nearPlane);
+
+    const glm::vec2 focal (
+	static_cast<float> (this->m_width) * 0.5f / tangents.x, static_cast<float> (this->m_height) * 0.5f / tangents.y
+    );
+
+    // re-sort only once the camera has moved enough to change the ordering visibly
+    const bool moved = !this->m_hasOrder
+	|| glm::distance (pose.position, this->m_sortedPosition) > this->m_base.distance * 0.0002f
+	|| glm::dot (forward, this->m_sortedForward) < 0.99999f;
+
+    std::vector<uint32_t> order;
+    bool haveNewOrder = false;
+
+    if (!this->m_hasOrder) {
+	// nothing to draw yet: this one has to be synchronous
+	this->m_sorter->sortNow (pose.position, forward, order);
+	this->m_hasOrder = true;
+	haveNewOrder = true;
+    } else if (this->m_sorter->takeResult (order)) {
+	haveNewOrder = true;
+    }
+
+    if (moved) {
+	this->m_sorter->request (pose.position, forward);
+	this->m_sortedPosition = pose.position;
+	this->m_sortedForward = forward;
+    }
+
+    if (haveNewOrder) {
+	glBindBuffer (GL_ARRAY_BUFFER, this->m_orderBuffer);
+	glBufferData (
+	    GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (order.size () * sizeof (uint32_t)), order.data (), GL_STREAM_DRAW
+	);
+	glBindBuffer (GL_ARRAY_BUFFER, GL_NONE);
+	this->m_drawCount = static_cast<uint32_t> (order.size ());
+    }
+
+    const bool clockChanged = this->updateClock (static_cast<float> (this->m_width) / static_cast<float> (this->m_height));
+
+    // a still camera and clock produce the exact same frame, which is still sitting in the output texture
+    if (this->m_hasDrawn && !haveNewOrder && !clockChanged && view == this->m_drawnView
+	&& projection == this->m_drawnProjection) {
+	return;
+    }
+
+    this->m_drawnView = view;
+    this->m_drawnProjection = projection;
+    this->m_hasDrawn = true;
+
+    glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
+    glViewport (0, 0, this->m_width, this->m_height);
+    glClearColor (0.0f, 0.0f, 0.0f, 1.0f);
+    glClear (GL_COLOR_BUFFER_BIT);
+
+    if (this->m_drawCount == 0) {
+	return;
+    }
+
+    glDisable (GL_DEPTH_TEST);
+    glDisable (GL_CULL_FACE);
+    glEnable (GL_BLEND);
+    // splats arrive back to front; keep the cleared alpha so the finished frame stays opaque
+    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
+
+    glUseProgram (this->m_program);
+
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, this->m_centerTexture);
+    glActiveTexture (GL_TEXTURE1);
+    glBindTexture (GL_TEXTURE_2D, this->m_rotationTexture);
+    glActiveTexture (GL_TEXTURE2);
+    glBindTexture (GL_TEXTURE_2D, this->m_scaleTexture);
+
+    glUniform1i (this->u_Centers, 0);
+    glUniform1i (this->u_Rotations, 1);
+    glUniform1i (this->u_Scales, 2);
+    glUniformMatrix4fv (this->u_View, 1, GL_FALSE, glm::value_ptr (view));
+    glUniformMatrix4fv (this->u_Projection, 1, GL_FALSE, glm::value_ptr (projection));
+    glUniform2f (this->u_Viewport, static_cast<float> (this->m_width), static_cast<float> (this->m_height));
+    glUniform2f (this->u_Focal, focal.x, focal.y);
+    glUniform1i (this->u_TextureWidth, static_cast<GLint> (this->m_textureWidth));
+    glUniform4fv (this->u_ClockPoints, static_cast<GLsizei> (CLOCK_POINT_CAPACITY), glm::value_ptr (this->m_clockPoints[0]));
+    glUniform4fv (this->u_ClockParams, 1, glm::value_ptr (this->m_clockParams));
+    glUniform4fv (this->u_ClockBounds, 1, glm::value_ptr (this->m_clockBounds));
+    glUniform1f (this->u_ClockDistance, this->m_clockDistance);
+
+    glBindVertexArray (this->m_vao);
+    glDrawArraysInstanced (GL_TRIANGLE_STRIP, 0, 4, static_cast<GLsizei> (this->m_drawCount));
+    glBindVertexArray (GL_NONE);
+
+    glDisable (GL_BLEND);
+    glActiveTexture (GL_TEXTURE0);
+}

+ 168 - 0
src/WallpaperEngine/Render/Wallpapers/CSplat.h

@@ -0,0 +1,168 @@
+#pragma once
+
+#include <array>
+#include <chrono>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <glm/glm.hpp>
+
+#include "WallpaperEngine/Render/CWallpaper.h"
+
+namespace WallpaperEngine::Render::Wallpapers {
+class SplatSorter;
+
+// Native renderer for the "SOG depth wallpaper" family: a Gaussian splat cloud (PlayCanvas SOG format) viewed through a camera at
+// the photo's origin that sways with the mouse to fake depth. These items normally host a SuperSplat viewer in CEF, which drew
+// torn patches under offscreen rendering, so the same data is rendered natively with the camera model ported from the wallpaper's JS.
+class CSplat : public CWallpaper {
+public:
+    CSplat (
+	const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
+	const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
+    );
+    ~CSplat () override;
+
+    // True for projects built on the SOG depth wallpaper base (they expose its sog* properties)
+    static bool supports (const Project& project);
+
+    [[nodiscard]] int getWidth () const override { return this->m_width; }
+    [[nodiscard]] int getHeight () const override { return this->m_height; }
+
+protected:
+    void renderFrame (const glm::ivec4& viewport) override;
+
+    friend class CWallpaper;
+
+private:
+    // Everything the wallpaper's camera needs from the splat cloud, in the viewer's world frame
+    // (the photo frame rotated 180 degrees around Z, see loadCloud())
+    struct SceneProfile {
+	// direction from the origin camera through the center of the visible content
+	glm::vec3 direction = { 0.0f, 0.0f, 1.0f };
+	// 2%..98% quantile range of splat depth along z, and its median
+	float depthMin = 1.0f;
+	float depthMax = 2.0f;
+	// horizontal/vertical angular size (radians) of the 2%..98% quantile range
+	glm::vec2 angularSize = { 1.0f, 1.0f };
+	glm::vec3 boundsCenter = {};
+	glm::vec3 boundsHalfExtents = {};
+    };
+
+    struct BaseCamera {
+	glm::vec3 target = {};
+	glm::vec3 position = {};
+	glm::vec3 front = {};
+	glm::vec3 right = {};
+	glm::vec3 up = {};
+	float distance = 1.0f;
+	float parallaxDepthFactor = 0.0f;
+	// z of the nearest face of the scene's bounding box, the plane the view must not leave
+	float frontZ = 0.0f;
+	// degrees, horizontal when the viewport is landscape, vertical otherwise
+	float fov = 60.0f;
+	glm::ivec2 viewport = {};
+	float focusDepth = 0.0f;
+    };
+
+    struct Pose {
+	glm::vec3 position = {};
+	glm::vec3 target = {};
+    };
+
+    // One capsule of the clock's seven segment digits, in screen space (0..1, y down)
+    struct ClockPoint {
+	float x = 0.0f;
+	float y = 0.0f;
+	// > 0 horizontal capsule, < 0 vertical, 0 a round dot; in aspect-corrected screen height units
+	float halfLength = 0.0f;
+	// 1 fully formed, ramping up from 0 while appearing, negative while sinking away
+	float strength = 0.0f;
+    };
+
+    static constexpr size_t CLOCK_POINT_CAPACITY = 32;
+
+    void loadCloud ();
+    void setupGL (const std::vector<float>& centers, const std::vector<float>& rotations, const std::vector<float>& scales);
+    void resizeOutput (int width, int height);
+
+    std::vector<ClockPoint> buildClockPoints (
+	const std::string& text, float aspect, float size, float originX, float originY
+    ) const;
+    // Advances the clock's animation and refreshes the uniform data below; true if anything changed
+    bool updateClock (float aspect);
+
+    BaseCamera buildBaseCamera (int width, int height, float focusDepth) const;
+    Pose posePlacement (const BaseCamera& base, glm::vec2 tilt, glm::vec2 orbit, float parallaxStrength) const;
+    bool viewFitsFrontFace (const glm::vec3& position, const glm::vec3& target, const BaseCamera& base) const;
+
+    [[nodiscard]] double numberProperty (const std::string& name, double fallback) const;
+    [[nodiscard]] std::string stringProperty (const std::string& name) const;
+
+    SceneProfile m_profile;
+    BaseCamera m_base;
+    bool m_hasBase = false;
+    // Workshop presets are single photos that rarely match the screen's aspect ratio: show the
+    // whole photo (bars at the sides on a wide screen) instead of the base page's crop-to-fill.
+    bool m_containFraming = false;
+
+    std::unique_ptr<SplatSorter> m_sorter;
+    uint32_t m_cloudCount = 0;
+    uint32_t m_textureWidth = 0;
+    uint32_t m_drawCount = 0;
+
+    GLuint m_program = GL_NONE;
+    GLuint m_vao = GL_NONE;
+    GLuint m_cornerBuffer = GL_NONE;
+    GLuint m_orderBuffer = GL_NONE;
+    GLuint m_centerTexture = GL_NONE;
+    GLuint m_rotationTexture = GL_NONE;
+    GLuint m_scaleTexture = GL_NONE;
+
+    GLint u_Centers = -1;
+    GLint u_Rotations = -1;
+    GLint u_Scales = -1;
+    GLint u_View = -1;
+    GLint u_Projection = -1;
+    GLint u_Viewport = -1;
+    GLint u_Focal = -1;
+    GLint u_TextureWidth = -1;
+    GLint u_ClockPoints = -1;
+    GLint u_ClockParams = -1;
+    GLint u_ClockBounds = -1;
+    GLint u_ClockDistance = -1;
+
+    int m_width = 16;
+    int m_height = 17;
+
+    std::string m_clockText;
+    std::vector<ClockPoint> m_clockCurrent;
+    std::vector<ClockPoint> m_clockPrevious;
+    std::chrono::steady_clock::time_point m_clockTransitionStart = {};
+    // the layout the current points were built for, so a settings change rebuilds them
+    std::array<float, 5> m_clockLayout = {};
+    std::array<glm::vec4, CLOCK_POINT_CAPACITY> m_clockPoints = {};
+    // enabled, lift, radius, aspect
+    glm::vec4 m_clockParams = {};
+    // screen area (min x/y, max x/y) the clock effect can touch, so other splats skip the field test
+    glm::vec4 m_clockBounds = {};
+    float m_clockDistance = 0.0f;
+
+    glm::vec2 m_tilt = {};
+    float m_orbitPhase = 0.0f;
+    std::chrono::steady_clock::time_point m_lastFrame = {};
+    bool m_hasLastFrame = false;
+
+    // what the current sort order was built for, so a new sort is only requested once the camera
+    // has moved enough to matter
+    glm::vec3 m_sortedPosition = { 1e9f, 1e9f, 1e9f };
+    glm::vec3 m_sortedForward = {};
+    bool m_hasOrder = false;
+
+    // the frame currently in the output texture; nothing is redrawn while the camera stands still
+    glm::mat4 m_drawnView = glm::mat4 (0.0f);
+    glm::mat4 m_drawnProjection = glm::mat4 (0.0f);
+    bool m_hasDrawn = false;
+};
+} // namespace WallpaperEngine::Render::Wallpapers

+ 111 - 0
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -22,6 +22,79 @@ struct OpaqueScriptableObjectAdapter {
     WallpaperEngine::Scripting::ScriptableObject& object;
 };
 
+// the object's address rides along as function data, safe because every ScriptableObject outlives the script context
+JSValue scriptableobject_playback_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t address = 0;
+    JS_ToInt64 (ctx, &address, func_data[0]);
+    auto* object = reinterpret_cast<WallpaperEngine::Scripting::ScriptableObject*> (static_cast<intptr_t> (address));
+
+    if (magic == 2) {
+	return JS_NewBool (ctx, object->isPlaying ());
+    }
+
+    object->setPlaying (magic == 0);
+
+    return JS_UNDEFINED;
+}
+
+// only scriptable layers can be handed to scripts, a plain group parent comes back as null
+JSValue scriptableobject_hierarchy_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t objectAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &objectAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* object = reinterpret_cast<WallpaperEngine::Scripting::ScriptableObject*> (static_cast<intptr_t> (objectAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+    const auto& scene = engine->getScene ();
+
+    if (magic == 0) {
+	const auto& parentId = object->getObject ().parent;
+
+	if (!parentId.has_value ()) {
+	    return JS_NULL;
+	}
+
+	const auto* parent = scene.getObject (*parentId);
+
+	if (parent == nullptr || !parent->is<WallpaperEngine::Scripting::ScriptableObject> ()) {
+	    return JS_NULL;
+	}
+
+	return engine->getAdapters ().object->instantiate (
+	    const_cast<WallpaperEngine::Scripting::ScriptableObject&> (
+		*parent->as<WallpaperEngine::Scripting::ScriptableObject> ()
+	    )
+	);
+    }
+
+    JSValue children = JS_NewArray (ctx);
+    uint32_t index = 0;
+
+    for (const auto* candidate : scene.getObjectsByRenderOrder ()) {
+	const auto& candidateParent = candidate->getObject ().parent;
+
+	if (!candidateParent.has_value () || *candidateParent != object->getObject ().id
+	    || !candidate->is<WallpaperEngine::Scripting::ScriptableObject> ()) {
+	    continue;
+	}
+
+	JS_SetPropertyUint32 (
+	    ctx, children, index++,
+	    engine->getAdapters ().object->instantiate (
+		const_cast<WallpaperEngine::Scripting::ScriptableObject&> (
+		    *candidate->as<WallpaperEngine::Scripting::ScriptableObject> ()
+		)
+	    )
+	);
+    }
+
+    return children;
+}
+
 JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
     JSClassID classId = 0;
 
@@ -43,6 +116,44 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 	return container->adapter.getEngine ().dynamicToJs (*property);
     }
 
+    static constexpr struct {
+	const char* name;
+	int magic;
+    } playbackCalls[] = { { "play", 0 }, { "pause", 1 }, { "stop", 1 }, { "isPlaying", 2 } };
+
+    for (const auto& call : playbackCalls) {
+	if (std::strcmp (name, call.name) == 0) {
+	    JSValue address[]
+		= { JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))) };
+
+	    return JS_NewCFunctionData (ctx, scriptableobject_playback_call, 0, call.magic, 1, address);
+	}
+    }
+
+    static constexpr struct {
+	const char* name;
+	int magic;
+    } hierarchyCalls[] = { { "getParent", 0 }, { "getChildren", 1 } };
+
+    for (const auto& call : hierarchyCalls) {
+	if (std::strcmp (name, call.name) == 0) {
+	    JSValue data[] = {
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))),
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->adapter.getEngine ()))),
+	    };
+
+	    return JS_NewCFunctionData (ctx, scriptableobject_hierarchy_call, 0, call.magic, 2, data);
+	}
+    }
+
+    if (std::strcmp (name, "name") == 0) {
+	return JS_NewString (ctx, container->object.getObject ().name.c_str ());
+    }
+
+    if (std::strcmp (name, "id") == 0) {
+	return JS_NewInt32 (ctx, container->object.getObject ().id);
+    }
+
     // "size" isn't a DynamicValue-backed property, but thisLayer.size is a commonly used part
     // of the WE scripting API, so it's special-cased here. Checked against the data model
     // (Image/Text) rather than the render object (CImage/CText) because scripted properties can

+ 24 - 9
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -113,14 +113,7 @@ template <int components> auto vector_get (JSContext* ctx, JSValue source) -> de
 	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
 	}
 
-	if (components <= 2 && JS_IsNumber (z)) {
-	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
-	}
-
-	if (components <= 3 && JS_IsNumber (w)) {
-	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
-	}
-
+	// a wider vector handed to a narrower target just loses its extra components
 	double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
 
 	JS_ToFloat64 (ctx, &xVal, x);
@@ -210,6 +203,11 @@ int vector_property_set (
 
     int tag = JS_VALUE_GET_TAG (val);
 
+    // reading an undeclared property assigns undefined, keep the component as it was instead of throwing
+    if (tag == JS_TAG_UNDEFINED || tag == JS_TAG_NULL) {
+	return 0;
+    }
+
     if (tag != JS_TAG_INT && !JS_TAG_IS_FLOAT64 (tag)) {
 	return -1;
     }
@@ -385,7 +383,24 @@ JSValue vector_constructor (JSContext* ctx, JSValueConst new_target, int argc, J
 
     // `new Vec3()` with no args is valid and expected to default to a zero vector - already
     // zero-initialized above, so nothing further to do here.
-    if (argc > 0) {
+    if (argc > 1) {
+	// new Vec3(x, y, z): one number per component, anything not given stays 0
+	double parts[4] = { 0.0, 0.0, 0.0, 0.0 };
+
+	for (int i = 0; i < argc && i < components; i++) {
+	    JS_ToFloat64 (ctx, &parts[i], argv[i]);
+	}
+
+	if constexpr (components == 2) {
+	    container->value.update (glm::vec2 (parts[0], parts[1]), DynamicValue::UpdateSource::Initialization);
+	} else if constexpr (components == 3) {
+	    container->value.update (glm::vec3 (parts[0], parts[1], parts[2]), DynamicValue::UpdateSource::Initialization);
+	} else {
+	    container->value.update (
+		glm::vec4 (parts[0], parts[1], parts[2], parts[3]), DynamicValue::UpdateSource::Initialization
+	    );
+	}
+    } else if (argc > 0) {
 	container->value.update (vector_get<components> (ctx, argv[0]), DynamicValue::UpdateSource::Initialization);
     }
 

+ 395 - 0
src/WallpaperEngine/Scripting/AnimationSystem.cpp

@@ -0,0 +1,395 @@
+#include "AnimationSystem.h"
+
+#include <algorithm>
+#include <cmath>
+
+#include "WallpaperEngine/Logging/Log.h"
+
+using namespace WallpaperEngine::Scripting;
+
+namespace {
+int NextSystemId = 0;
+std::map<int, AnimationSystem*> Systems;
+
+int componentCount (DynamicValue::UnderlyingType type) {
+    switch (type) {
+	case DynamicValue::Float:
+	    return 1;
+	case DynamicValue::Vec2:
+	    return 2;
+	case DynamicValue::Vec3:
+	    return 3;
+	case DynamicValue::Vec4:
+	    return 4;
+	default:
+	    return 0;
+    }
+}
+
+glm::vec4 readComponents (const DynamicValue& value) {
+    switch (value.getType ()) {
+	case DynamicValue::Float:
+	    return { value.getFloat (), 0.0f, 0.0f, 0.0f };
+	case DynamicValue::Vec2:
+	    return { value.getVec2 (), 0.0f, 0.0f };
+	case DynamicValue::Vec3:
+	    return { value.getVec3 (), 0.0f };
+	case DynamicValue::Vec4:
+	    return value.getVec4 ();
+	default:
+	    return glm::vec4 (0.0f);
+    }
+}
+
+void writeComponents (DynamicValue& value, const glm::vec4& components) {
+    const auto source = DynamicValue::UpdateSource::Script;
+
+    switch (value.getType ()) {
+	case DynamicValue::Float:
+	    value.update (components.x, source);
+	    break;
+	case DynamicValue::Vec2:
+	    value.update (glm::vec2 (components), source);
+	    break;
+	case DynamicValue::Vec3:
+	    value.update (glm::vec3 (components), source);
+	    break;
+	case DynamicValue::Vec4:
+	    value.update (components, source);
+	    break;
+	default:
+	    break;
+    }
+}
+
+float cubicBezier (float p0, float p1, float p2, float p3, float t) {
+    const float u = 1.0f - t;
+    return u * u * u * p0 + 3.0f * u * u * t * p1 + 3.0f * u * t * t * p2 + t * t * t * p3;
+}
+
+float evaluateSegment (const AnimationKeyframe& a, const AnimationKeyframe& b, float frame) {
+    const float span = b.frame - a.frame;
+
+    if (span <= 0.0f) {
+	return b.value;
+    }
+
+    if (!a.front.enabled || !b.back.enabled) {
+	return a.value + (b.value - a.value) * ((frame - a.frame) / span);
+    }
+
+    // handles are offsets from their keyframe; keep the x control points inside the segment so the
+    // curve stays a function of time
+    const float x1 = std::clamp (a.frame + a.front.x, a.frame, b.frame);
+    const float x2 = std::clamp (b.frame + b.back.x, a.frame, b.frame);
+    const float y1 = a.value + a.front.y;
+    const float y2 = b.value + b.back.y;
+
+    float low = 0.0f;
+    float high = 1.0f;
+
+    for (int i = 0; i < 24; i++) {
+	const float mid = (low + high) * 0.5f;
+
+	if (cubicBezier (a.frame, x1, x2, b.frame, mid) < frame) {
+	    low = mid;
+	} else {
+	    high = mid;
+	}
+    }
+
+    return cubicBezier (a.value, y1, y2, b.value, (low + high) * 0.5f);
+}
+
+float evaluateCurve (const std::vector<AnimationKeyframe>& keys, float frame, bool wrap, float length) {
+    if (keys.size () == 1 || frame <= keys.front ().frame) {
+	return keys.front ().value;
+    }
+
+    if (frame >= keys.back ().frame) {
+	if (wrap && length > 0.0f && frame < keys.front ().frame + length) {
+	    AnimationKeyframe first = keys.front ();
+	    first.frame += length;
+	    return evaluateSegment (keys.back (), first, frame);
+	}
+
+	return keys.back ().value;
+    }
+
+    for (size_t i = 0; i + 1 < keys.size (); i++) {
+	if (frame < keys[i + 1].frame) {
+	    return evaluateSegment (keys[i], keys[i + 1], frame);
+	}
+    }
+
+    return keys.back ().value;
+}
+} // namespace
+
+AnimationClock::AnimationClock (
+    int id, DynamicValue& rootValue, std::shared_ptr<const PropertyAnimation> definition
+) : m_id (id), m_rootValue (&rootValue), m_definition (std::move (definition)) {
+    m_playing = !m_definition->startPaused;
+}
+
+void AnimationClock::addBinding (DynamicValue& value, std::shared_ptr<const PropertyAnimation> data) {
+    m_bindings.push_back (Binding { .value = &value, .data = std::move (data) });
+}
+
+void AnimationClock::stop () {
+    m_playing = false;
+    this->setFrame (0.0f);
+}
+
+void AnimationClock::setFrame (float frame) {
+    m_frame = std::clamp (frame, 0.0f, std::max (m_definition->length, 0.0f));
+    this->applyCurrentFrame ();
+}
+
+void AnimationClock::applyCurrentFrame () {
+    for (auto& binding : m_bindings) {
+	this->applyBinding (binding);
+    }
+}
+
+void AnimationClock::applyBinding (Binding& binding) const {
+    const int count = componentCount (binding.value->getType ());
+
+    if (count == 0) {
+	return;
+    }
+
+    const glm::vec4 current = readComponents (*binding.value);
+    glm::vec4 target = current;
+    const bool wrap = m_definition->mode == PropertyAnimation::Mode::Loop && binding.data->wrapLoop;
+
+    for (int component = 0; component < count; component++) {
+	const auto& keys = binding.data->curves[component];
+
+	if (keys.empty ()) {
+	    continue;
+	}
+
+	const float sampled = evaluateCurve (keys, m_frame, wrap, m_definition->length);
+
+	if (binding.data->relative) {
+	    // fold the offset in on top of whatever the value is now, so a script moving the base
+	    // value around keeps working
+	    target[component] = current[component] - binding.lastOffset[component] + sampled;
+	    binding.lastOffset[component] = sampled;
+	} else {
+	    target[component] = sampled;
+	}
+    }
+
+    if (target != current) {
+	writeComponents (*binding.value, target);
+    }
+}
+
+void AnimationClock::collectEvents (float from, float to, bool includeFrom, std::vector<FiredEvent>& out) const {
+    const bool forward = to >= from;
+    std::vector<const AnimationEvent*> hits;
+
+    for (const auto& event : m_definition->events) {
+	const bool inside = forward ? (includeFrom ? event.frame >= from : event.frame > from) && event.frame <= to
+				    : (includeFrom ? event.frame <= from : event.frame < from) && event.frame >= to;
+
+	if (inside) {
+	    hits.push_back (&event);
+	}
+    }
+
+    std::ranges::stable_sort (hits, [forward] (const AnimationEvent* a, const AnimationEvent* b) {
+	return forward ? a->frame < b->frame : a->frame > b->frame;
+    });
+
+    for (const auto* hit : hits) {
+	out.push_back (FiredEvent { .name = hit->name, .frame = hit->frame });
+    }
+}
+
+std::vector<AnimationClock::FiredEvent> AnimationClock::tick (float deltaSeconds) {
+    std::vector<FiredEvent> fired;
+    const float length = m_definition->length;
+
+    if (!m_playing || length <= 0.0f) {
+	return fired;
+    }
+
+    const float step = deltaSeconds * m_definition->fps * m_rate * m_direction;
+
+    if (step == 0.0f) {
+	return fired;
+    }
+
+    float from = m_frame;
+    float to = from + step;
+    bool forward = step > 0.0f;
+    bool includeFrom = false;
+
+    // a long frame can cross the end several times; anything past this is just phase
+    for (int wraps = 0; wraps < 4; wraps++) {
+	const bool pastEnd = forward ? to >= length : to <= 0.0f;
+
+	if (!pastEnd) {
+	    break;
+	}
+
+	const float edge = forward ? length : 0.0f;
+	this->collectEvents (from, edge, includeFrom, fired);
+	includeFrom = true;
+
+	if (m_definition->mode == PropertyAnimation::Mode::Single) {
+	    m_frame = edge;
+	    m_playing = false;
+	    this->applyCurrentFrame ();
+	    return fired;
+	}
+
+	if (m_definition->mode == PropertyAnimation::Mode::Loop) {
+	    to = forward ? to - length : to + length;
+	    from = forward ? 0.0f : length;
+	} else {
+	    to = forward ? 2.0f * length - to : -to;
+	    from = edge;
+	    forward = !forward;
+	    m_direction = -m_direction;
+	}
+    }
+
+    to = std::clamp (to, 0.0f, length);
+    this->collectEvents (from, to, includeFrom, fired);
+    m_frame = to;
+    this->applyCurrentFrame ();
+
+    return fired;
+}
+
+AnimationSystem::AnimationSystem () : m_id (++NextSystemId) { Systems.emplace (m_id, this); }
+
+AnimationSystem::~AnimationSystem () { Systems.erase (m_id); }
+
+AnimationSystem* AnimationSystem::find (int id) {
+    const auto it = Systems.find (id);
+
+    return it == Systems.end () ? nullptr : it->second;
+}
+
+void AnimationSystem::add (const std::string& group, const std::string& key, DynamicValue& value) {
+    if (value.getAnimation () == nullptr) {
+	return;
+    }
+
+    m_pending.push_back (Entry { .group = group, .key = key, .value = &value });
+    m_linked = false;
+}
+
+void AnimationSystem::remove (const DynamicValue& value) {
+    std::erase_if (m_pending, [&value] (const Entry& entry) { return entry.value == &value; });
+}
+
+AnimationClock* AnimationSystem::clockOf (const DynamicValue& value) {
+    if (!m_linked) {
+	this->link ();
+    }
+
+    const auto it = m_byValue.find (&value);
+
+    return it == m_byValue.end () ? nullptr : it->second;
+}
+
+AnimationClock* AnimationSystem::clock (int id) {
+    for (const auto& clock : m_clocks) {
+	if (clock->getId () == id) {
+	    return clock.get ();
+	}
+    }
+
+    return nullptr;
+}
+
+void AnimationSystem::link () {
+    m_linked = true;
+
+    for (const auto& entry : m_pending) {
+	m_groups[entry.group].emplace (entry.key, entry.value);
+    }
+
+    // follows parent links to the property that owns the playback state
+    const auto resolveRoot = [this] (const Entry& entry) {
+	DynamicValue* current = entry.value;
+
+	for (int depth = 0; depth < 8; depth++) {
+	    const auto& parentKey = current->getAnimation ()->parent;
+
+	    if (!parentKey.has_value ()) {
+		break;
+	    }
+
+	    const auto& siblings = m_groups[entry.group];
+	    const auto parent = siblings.find (*parentKey);
+
+	    if (parent == siblings.end () || parent->second == current || parent->second->getAnimation () == nullptr) {
+		break;
+	    }
+
+	    current = parent->second;
+	}
+
+	return current;
+    };
+
+    static int nextClockId = 0;
+
+    for (const auto& entry : m_pending) {
+	DynamicValue* root = resolveRoot (entry);
+
+	if (m_byValue.contains (root)) {
+	    continue;
+	}
+
+	auto clock = std::make_unique<AnimationClock> (++nextClockId, *root, root->getAnimation ());
+	m_byValue.emplace (root, clock.get ());
+	m_clocks.push_back (std::move (clock));
+    }
+
+    std::set<AnimationClock*> touched;
+
+    for (const auto& entry : m_pending) {
+	if (!m_bound.insert (entry.value).second) {
+	    continue;
+	}
+
+	auto* clock = m_byValue.at (resolveRoot (entry));
+	clock->addBinding (*entry.value, entry.value->getAnimation ());
+	m_byValue.emplace (entry.value, clock);
+	touched.insert (clock);
+    }
+
+    // paused timelines still show their first frame, which is what hides collapsed UI at startup
+    for (auto* clock : touched) {
+	clock->applyCurrentFrame ();
+    }
+
+    m_pending.clear ();
+}
+
+void AnimationSystem::tick (float deltaSeconds) {
+    if (!m_linked) {
+	this->link ();
+    }
+
+    for (const auto& clock : m_clocks) {
+	for (auto& event : clock->tick (deltaSeconds)) {
+	    m_events.push_back (PendingEvent { .clock = clock->getId (), .name = std::move (event.name), .frame = event.frame });
+	}
+    }
+}
+
+std::vector<AnimationSystem::PendingEvent> AnimationSystem::takeEvents () {
+    std::vector<PendingEvent> events;
+    events.swap (m_events);
+
+    return events;
+}

+ 113 - 0
src/WallpaperEngine/Scripting/AnimationSystem.h

@@ -0,0 +1,113 @@
+#pragma once
+
+#include <glm/vec4.hpp>
+#include <map>
+#include <memory>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/PropertyAnimation.h"
+
+namespace WallpaperEngine::Scripting {
+using namespace WallpaperEngine::Data::Model;
+
+/**
+ * One playback timeline shared by a root animated property and every property that lists it as
+ * its parent (e.g. an origin animation driving the scale one, or a shader's Radius driving Size).
+ */
+class AnimationClock {
+public:
+    struct Binding {
+	DynamicValue* value;
+	std::shared_ptr<const PropertyAnimation> data;
+	glm::vec4 lastOffset { 0.0f };
+    };
+
+    struct FiredEvent {
+	std::string name;
+	float frame;
+    };
+
+    AnimationClock (int id, DynamicValue& rootValue, std::shared_ptr<const PropertyAnimation> definition);
+
+    [[nodiscard]] int getId () const { return m_id; }
+    [[nodiscard]] DynamicValue& getRootValue () const { return *m_rootValue; }
+    [[nodiscard]] const PropertyAnimation& getDefinition () const { return *m_definition; }
+    [[nodiscard]] float getFrame () const { return m_frame; }
+    [[nodiscard]] bool isPlaying () const { return m_playing; }
+    [[nodiscard]] float getRate () const { return m_rate; }
+
+    void addBinding (DynamicValue& value, std::shared_ptr<const PropertyAnimation> data);
+    void play () { m_playing = true; }
+    void pause () { m_playing = false; }
+    void stop ();
+    void setRate (float rate) { m_rate = rate; }
+    void setFrame (float frame);
+    /** Advances the timeline, applies it to every bound value and returns the events it crossed */
+    std::vector<FiredEvent> tick (float deltaSeconds);
+    void applyCurrentFrame ();
+
+private:
+    void collectEvents (float from, float to, bool includeFrom, std::vector<FiredEvent>& out) const;
+    void applyBinding (Binding& binding) const;
+
+    int m_id;
+    DynamicValue* m_rootValue;
+    std::shared_ptr<const PropertyAnimation> m_definition;
+    std::vector<Binding> m_bindings;
+    float m_frame = 0.0f;
+    float m_rate = 1.0f;
+    // only ever flips for mirror mode
+    float m_direction = 1.0f;
+    bool m_playing = true;
+};
+
+class AnimationSystem {
+public:
+    struct PendingEvent {
+	int clock;
+	std::string name;
+	float frame;
+    };
+
+    AnimationSystem ();
+    ~AnimationSystem ();
+    AnimationSystem (const AnimationSystem&) = delete;
+    AnimationSystem& operator= (const AnimationSystem&) = delete;
+
+    [[nodiscard]] int getId () const { return m_id; }
+    static AnimationSystem* find (int id);
+
+    /**
+     * Registers an animated property. `group` scopes the parent/children links between siblings
+     * (one object's transform properties, or one effect pass's constants).
+     */
+    void add (const std::string& group, const std::string& key, DynamicValue& value);
+    /** Drops a not-yet-linked registration, used when a later registration supersedes it */
+    void remove (const DynamicValue& value);
+    void tick (float deltaSeconds);
+    std::vector<PendingEvent> takeEvents ();
+    AnimationClock* clockOf (const DynamicValue& value);
+    AnimationClock* clock (int id);
+
+private:
+    struct Entry {
+	std::string group;
+	std::string key;
+	DynamicValue* value;
+    };
+
+    void link ();
+
+    int m_id;
+    bool m_linked = false;
+    std::vector<Entry> m_pending;
+    std::map<std::string, std::map<std::string, DynamicValue*>> m_groups;
+    std::set<const DynamicValue*> m_bound;
+    std::vector<std::unique_ptr<AnimationClock>> m_clocks;
+    std::map<const DynamicValue*, AnimationClock*> m_byValue;
+    std::vector<PendingEvent> m_events;
+};
+} // namespace WallpaperEngine::Scripting

+ 95 - 17
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -4,6 +4,7 @@
 
 #include "WallpaperEngine/Audio/Drivers/AudioDriver.h"
 #include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
+#include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 
 #include <ranges>
@@ -19,10 +20,35 @@ std::map<uint32_t, EngineObject&> engineInstances;
 
 JSValue engine_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
 
+// rebuilt on every read so scripts always see the current values
+JSValue engine_get_user_properties (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic
+) {
+    JSValue result = JS_NewObject (ctx);
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return result;
+    }
+
+    auto& engine = it->second.getEngine ();
+
+    for (const auto& [name, property] : it->second.getScene ().getUserProperties ()) {
+	JS_SetPropertyStr (ctx, result, name.c_str (), engine.userPropertyToJs (*property));
+    }
+
+    return result;
+}
+
 JSValue engine_open_user_shortcut (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     return JS_UNDEFINED;
 }
 
+// engine.isRunningInEditor() and friends: fixed answers, this is always a plain desktop wallpaper
+JSValue engine_query_flag (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    return JS_NewBool (ctx, magic != 0);
+}
+
 JSValue engine_get_frametime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     return JS_NewFloat64 (ctx, g_Time - g_TimeLast);
 }
@@ -249,6 +275,14 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	JS_NewCFunction (this->m_engine.getContext (), engine_get_daytime, "get", 0),
 	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "userProperties"),
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_get_user_properties, "get", 0, JS_CFUNC_generic_magic,
+	    this->m_instanceId
+	),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "AUDIO_RESOLUTION_16",
 	JS_NewInt32 (this->m_engine.getContext (), 16), JS_PROP_ENUMERABLE
@@ -290,6 +324,22 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	),
 	JS_PROP_ENUMERABLE
     );
+    const struct {
+	const char* name;
+	int answer;
+    } flags[] = {
+	{ "isRunningInEditor", 0 }, { "isDesktopDevice", 1 }, { "isMobileDevice", 0 },
+	{ "isWallpaper", 1 },	    { "isScreensaver", 0 },
+    };
+    for (const auto& flag : flags) {
+	JS_DefinePropertyValueStr (
+	    this->m_engine.getContext (), this->m_instance, flag.name,
+	    JS_NewCFunctionMagic (
+		this->m_engine.getContext (), engine_query_flag, flag.name, 0, JS_CFUNC_generic_magic, flag.answer
+	    ),
+	    JS_PROP_ENUMERABLE
+	);
+    }
     // TODO: ADD THE REST OF THE DEFINITION!
 }
 
@@ -311,7 +361,7 @@ EngineObject::~EngineObject () {
 uint32_t EngineObject::reserveNextTimeoutId (JSValue function, uint64_t duration) {
     const auto id = ++this->m_nextTimeoutId;
 
-    this->m_timeouts[id] = Timeout { .callback = function,
+    this->m_timeouts[id] = Timeout { .callback = JS_DupValue (this->m_engine.getContext (), function),
 				     .duration = std::chrono::milliseconds (duration),
 				     .next = std::chrono::steady_clock::now () + std::chrono::milliseconds (duration) };
 
@@ -322,7 +372,7 @@ uint32_t EngineObject::reserveNextIntervalId (JSValue function, uint64_t duratio
     const auto id = ++this->m_nextIntervalId;
 
     this->m_intervals[id]
-	= Timeout { .callback = function,
+	= Timeout { .callback = JS_DupValue (this->m_engine.getContext (), function),
 		    .duration = std::chrono::milliseconds (duration),
 		    .next = std::chrono::steady_clock::now () + std::chrono::milliseconds (duration) };
 
@@ -353,34 +403,62 @@ void EngineObject::clearTimeout (uint32_t id) {
     this->m_timeouts.erase (id);
 }
 
+static void callTimerCallback (JSContext* ctx, JSValueConst callback, const char* kind) {
+    JSValue result = JS_Call (ctx, callback, JS_NULL, 0, nullptr);
+
+    if (JS_IsException (result)) {
+	logJSException (ctx, kind);
+    }
+
+    JS_FreeValue (ctx, result);
+}
+
 void EngineObject::tick () {
     const auto now = std::chrono::steady_clock::now ();
+    auto* ctx = this->m_engine.getContext ();
+
+    // only ids are collected up front, callbacks may clearInterval()/setTimeout() from inside themselves
+    std::vector<uint32_t> dueIntervals;
 
-    for (auto& timeout : this->m_intervals | std::views::values) {
-	if (timeout.next > now) {
+    for (const auto& [id, interval] : this->m_intervals) {
+	if (interval.next <= now) {
+	    dueIntervals.push_back (id);
+	}
+    }
+
+    for (const auto id : dueIntervals) {
+	const auto it = this->m_intervals.find (id);
+
+	if (it == this->m_intervals.end ()) {
 	    continue;
 	}
 
-	timeout.next = now + timeout.duration;
+	it->second.next = now + it->second.duration;
 
-	JS_Call (this->m_engine.getContext (), timeout.callback, JS_NULL, 0, nullptr);
+	JSValue callback = JS_DupValue (ctx, it->second.callback);
+	callTimerCallback (ctx, callback, "setInterval");
+	JS_FreeValue (ctx, callback);
     }
 
-    std::vector<uint32_t> removeTimeouts;
+    std::vector<uint32_t> dueTimeouts;
 
-    for (auto& [id, timeout] : this->m_timeouts) {
-	if (timeout.next > now) {
-	    continue;
+    for (const auto& [id, timeout] : this->m_timeouts) {
+	if (timeout.next <= now) {
+	    dueTimeouts.push_back (id);
 	}
+    }
 
-	JS_Call (this->m_engine.getContext (), timeout.callback, JS_NULL, 0, nullptr);
+    for (const auto id : dueTimeouts) {
+	const auto it = this->m_timeouts.find (id);
 
-	JS_FreeValue (this->m_engine.getContext (), timeout.callback);
+	if (it == this->m_timeouts.end ()) {
+	    continue;
+	}
 
-	removeTimeouts.push_back (id);
-    }
+	JSValue callback = it->second.callback;
+	this->m_timeouts.erase (it);
 
-    for (auto id : removeTimeouts) {
-	this->m_timeouts.erase (id);
+	callTimerCallback (ctx, callback, "setTimeout");
+	JS_FreeValue (ctx, callback);
     }
-}
+}

+ 21 - 34
src/WallpaperEngine/Scripting/Modules/ColorModule.cpp

@@ -10,12 +10,24 @@ using namespace WallpaperEngine::Scripting::Modules;
 static uint32_t ColorModuleInstanceId = 0;
 std::map<uint32_t, ColorModule&> colorModules;
 
-int wecolor_init (JSContext* ctx, JSModuleDef* m) {
+JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
 
-    JS_AddModuleExport (ctx, m, "rgb2hsv");
-    JS_AddModuleExport (ctx, m, "hsv2rgb");
-    JS_AddModuleExport (ctx, m, "normalizeColor");
-    JS_AddModuleExport (ctx, m, "expandColor");
+int wecolor_init (JSContext* ctx, JSModuleDef* m) {
+    JS_SetModuleExport (
+	ctx, m, "rgb2hsv", JS_NewCFunctionMagic (ctx, wecolor_rgb2hsv, "rgb2hsv", 1, JS_CFUNC_generic_magic, 0)
+    );
+    JS_SetModuleExport (
+	ctx, m, "hsv2rgb", JS_NewCFunctionMagic (ctx, wecolor_hsv2rgb, "hsv2rgb", 1, JS_CFUNC_generic_magic, 0)
+    );
+    JS_SetModuleExport (
+	ctx, m, "normalizeColor", JS_NewCFunctionMagic (ctx, wecolor_normalizecolor, "normalizeColor", 1, JS_CFUNC_generic_magic, 0)
+    );
+    JS_SetModuleExport (
+	ctx, m, "expandColor", JS_NewCFunctionMagic (ctx, wecolor_expandcolor, "expandColor", 1, JS_CFUNC_generic_magic, 0)
+    );
 
     return 0;
 }
@@ -230,35 +242,10 @@ JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JS
 ColorModule::ColorModule (ScriptEngine& engine) : ScriptModule (engine, "WEColor", wecolor_init) {
     this->m_instanceId = ++ColorModuleInstanceId;
 
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "rgb2hsv",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wecolor_rgb2hsv, "rgb2hsv", 1, JS_CFUNC_generic_magic, this->m_instanceId
-	)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "hsv2rgb",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wecolor_hsv2rgb, "hsv2rgb", 1, JS_CFUNC_generic_magic, this->m_instanceId
-	)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "normalizeColor",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wecolor_normalizecolor, "normalizeColor", 1, JS_CFUNC_generic_magic,
-	    this->m_instanceId
-	)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "expandColor",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wecolor_expandcolor, "expandColor", 1, JS_CFUNC_generic_magic,
-	    this->m_instanceId
-	)
-    );
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "rgb2hsv");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "hsv2rgb");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "normalizeColor");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "expandColor");
 
     colorModules.emplace (this->m_instanceId, *this);
 }

+ 15 - 29
src/WallpaperEngine/Scripting/Modules/MathModule.cpp

@@ -10,12 +10,18 @@ using namespace WallpaperEngine::Scripting::Modules;
 static uint32_t MathModuleInstanceId = 0;
 std::map<uint32_t, MathModule&> mathModules;
 
-int wemath_init (JSContext* ctx, JSModuleDef* m) {
+JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
 
-    JS_AddModuleExport (ctx, m, "smoothStep");
-    JS_AddModuleExport (ctx, m, "mix");
-    JS_AddModuleExport (ctx, m, "deg2rad");
-    JS_AddModuleExport (ctx, m, "rad2deg");
+int wemath_init (JSContext* ctx, JSModuleDef* m) {
+    JS_SetModuleExport (
+	ctx, m, "smoothStep", JS_NewCFunctionMagic (ctx, wemath_smoothstep, "smoothStep", 3, JS_CFUNC_generic_magic, 0)
+    );
+    JS_SetModuleExport (
+	ctx, m, "mix", JS_NewCFunctionMagic (ctx, wemath_mix, "mix", 3, JS_CFUNC_generic_magic, 0)
+    );
+    JS_SetModuleExport (ctx, m, "deg2rad", JS_NewFloat64 (ctx, 0.01745329251994329576923690768489));
+    JS_SetModuleExport (ctx, m, "rad2deg", JS_NewFloat64 (ctx, 57.295779513082320876798154814105));
 
     return 0;
 }
@@ -63,30 +69,10 @@ JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueCons
 MathModule::MathModule (ScriptEngine& engine) : ScriptModule (engine, "WEMath", wemath_init) {
     this->m_instanceId = ++MathModuleInstanceId;
 
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "smoothStep",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wemath_smoothstep, "smoothStep", 3, JS_CFUNC_generic_magic,
-	    this->m_instanceId
-	)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "mix",
-	JS_NewCFunctionMagic (
-	    this->getEngine ().getContext (), wemath_mix, "mix", 1, JS_CFUNC_generic_magic, this->m_instanceId
-	)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "deg2rad",
-	JS_NewFloat64 (this->getEngine ().getContext (), 0.01745329251994329576923690768489)
-    );
-
-    JS_SetModuleExport (
-	this->getEngine ().getContext (), this->getDefinition (), "rad2deg",
-	JS_NewFloat64 (this->getEngine ().getContext (), 57.295779513082320876798154814105)
-    );
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "smoothStep");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "mix");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "deg2rad");
+    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "rad2deg");
 
     mathModules.emplace (this->m_instanceId, *this);
 }

+ 47 - 0
src/WallpaperEngine/Scripting/SceneObject.cpp

@@ -133,6 +133,47 @@ JSValue get_cameraparallaxmouseinfluence (JSContext* ctx, JSValueConst this_val,
     return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.parallax.mouseInfluence->value->getFloat ());
 }
 
+// Sound objects aren't ScriptableObjects, so getLayer() hands scripts a handle with just the playback calls;
+// it goes through the scene by id because sounds may not exist yet while init() runs
+JSValue sound_layer_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t address = 0;
+    int id = 0;
+    int startsilent = 0;
+    JS_ToInt64 (ctx, &address, func_data[0]);
+    JS_ToInt32 (ctx, &id, func_data[1]);
+    JS_ToInt32 (ctx, &startsilent, func_data[2]);
+    auto* scene = reinterpret_cast<WallpaperEngine::Render::Wallpapers::CScene*> (static_cast<intptr_t> (address));
+
+    switch (magic) {
+	case 0:
+	    scene->setSoundPlaying (id, true);
+	    return JS_UNDEFINED;
+	case 1:
+	    scene->setSoundPlaying (id, false);
+	    return JS_UNDEFINED;
+	default:
+	    return JS_NewBool (ctx, scene->getSoundPlayRequest (id).value_or (startsilent == 0));
+    }
+}
+
+JSValue instantiate_sound_layer (JSContext* ctx, WallpaperEngine::Render::Wallpapers::CScene& scene, const Sound& sound) {
+    JSValue handle = JS_NewObject (ctx);
+    JSValue data[] = { JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&scene))),
+		       JS_NewInt32 (ctx, sound.id), JS_NewInt32 (ctx, sound.startsilent.value_or (false) ? 1 : 0) };
+    static constexpr struct {
+	const char* name;
+	int magic;
+    } calls[] = { { "play", 0 }, { "stop", 1 }, { "pause", 1 }, { "isPlaying", 2 } };
+
+    for (const auto& call : calls) {
+	JS_SetPropertyStr (ctx, handle, call.name, JS_NewCFunctionData (ctx, sound_layer_call, 0, call.magic, 3, data));
+    }
+
+    return handle;
+}
+
 JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     if (argc != 1) {
 	return JS_EXCEPTION;
@@ -170,6 +211,12 @@ JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst
 
 	ScopeGuard guard ([=] { JS_FreeCString (ctx, result); });
 
+	for (const auto& data : container->getScene ().getScene ().objects) {
+	    if (data->name == result && data->is<Sound> ()) {
+		return instantiate_sound_layer (ctx, container->getScene (), *data->as<Sound> ());
+	    }
+	}
+
 	for (auto object : container->getScene ().getObjectsByRenderOrder ()) {
 	    if (object->getObject ().name != result) {
 		continue;

+ 403 - 24
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -1,4 +1,6 @@
 #include "ScriptEngine.h"
+#include "WallpaperEngine/Media/ThumbnailPalette.h"
+#include "stb_image.h"
 
 #include "Adapters/ScriptableObjectAdapter.h"
 #include "Modules/ColorModule.h"
@@ -8,6 +10,7 @@
 #include "ScriptableObject.h"
 #include "WallpaperEngine/Audio/AudioContext.h"
 #include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
+#include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/CObject.h"
@@ -106,6 +109,22 @@ JSValue ScriptEngine::dynamicToJs (DynamicValue& value) const {
     }
 }
 
+bool ScriptEngine::hasScript (const DynamicValue& value) const {
+    return std::ranges::any_of (this->m_scriptModules, [&value] (const auto& entry) {
+	return &entry.second.value == &value;
+    });
+}
+
+JSValue ScriptEngine::userPropertyToJs (Property& property) const {
+    if (property.is<PropertyColor> () && property.getType () == DynamicValue::Vec4) {
+	DynamicValue rgb (glm::vec3 (property.getVec4 ()));
+
+	return this->m_adapters.vec3->instantiate (rgb, true);
+    }
+
+    return this->dynamicToJs (property);
+}
+
 static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source) {
     if (JS_IsException (val)) {
 	return;
@@ -130,6 +149,7 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 
     if (tag == JS_TAG_BOOL) {
 	source.update (static_cast<bool> (JS_VALUE_GET_BOOL (val)), DynamicValue::UpdateSource::Script);
+	return;
     }
 
     if (JS_TAG_IS_FLOAT64 (tag)) {
@@ -139,7 +159,7 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 
     if (tag == JS_TAG_STRING) {
 	const char* str = JS_ToCString (ctx, val);
-	source.update (str == nullptr ? "" : str, DynamicValue::UpdateSource::Script);
+	source.update (std::string (str == nullptr ? "" : str), DynamicValue::UpdateSource::Script);
 	JS_FreeCString (ctx, str);
 	return;
     }
@@ -170,11 +190,14 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 	    return;
 	}
 
+	JS_ToFloat64 (ctx, &zVal, z);
+
 	if (!JS_IsNumber (w)) {
 	    source.update (glm::vec3 (xVal, yVal, zVal), DynamicValue::UpdateSource::Script);
 	    return;
 	}
 
+	JS_ToFloat64 (ctx, &wVal, w);
 	source.update (glm::vec4 (xVal, yVal, zVal, wVal), DynamicValue::UpdateSource::Script);
     }
 }
@@ -259,6 +282,7 @@ ScriptEngine::~ScriptEngine () {
 
     for (const auto& module : this->m_scriptModules | std::views::values) {
 	JS_FreeValue (this->m_context, module.module);
+	JS_FreeValue (this->m_context, module.thisObject);
     }
 
     JS_FreeValue (this->m_context, this->m_globalThis);
@@ -287,7 +311,7 @@ ScriptEngine::~ScriptEngine () {
     this->m_adapters.object.reset ();
 }
 
-static void logJSException (JSContext* ctx, const char* context) {
+void WallpaperEngine::Scripting::logJSException (JSContext* ctx, const char* context) {
     JSValue exc = JS_GetException (ctx);
     if (!JS_IsNull (exc) && !JS_IsUndefined (exc)) {
 	const char* str = JS_ToCString (ctx, exc);
@@ -574,7 +598,33 @@ JSValue ScriptEngine::call (JSValue module, int argc, JSValue argv[], const char
     return JS_Call (this->m_context, function, module, argc, argv);
 }
 
-void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentValue, ScriptableObject& object) {
+void ScriptEngine::retireScript (const std::string& key) { this->m_retiredScriptKeys.push_back (key); }
+
+bool ScriptEngine::rebindScript (const std::string& key, DynamicValue& newValue) {
+    const auto it = this->m_scriptModules.find (key);
+
+    if (it == this->m_scriptModules.end () || it->second.value.getScriptSource () != newValue.getScriptSource ()) {
+	return false;
+    }
+
+    // LoadedModule::value is a reference member, so the entry is erased and re-emplaced under the same key,
+    // m_runningModule may currently point at the erased entry
+    // the cached thisObject points at the old value, the replacement builds its own
+    JS_FreeValue (this->m_context, it->second.thisObject);
+    LoadedModule replacement { .value = newValue,
+			       .module = it->second.module,
+			       .object = it->second.object,
+			       .propertyName = it->second.propertyName };
+    this->m_scriptModules.erase (it);
+    const auto inserted = this->m_scriptModules.emplace (key, replacement);
+    this->m_runningModule = &inserted.first->second;
+
+    return true;
+}
+
+void ScriptEngine::queueScript (
+    const std::string& key, DynamicValue& currentValue, ScriptableObject& object, const std::string& propertyName
+) {
     const auto source = currentValue.getScriptSource ();
 
     if (!source.has_value ()) {
@@ -614,6 +664,7 @@ void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentVal
 	    .value = currentValue,
 	    .module = JS_UNDEFINED,
 	    .object = &object,
+	    .propertyName = propertyName,
 	}
     );
 
@@ -624,6 +675,9 @@ void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentVal
 
     this->m_runningModule = &inserted.first->second;
 
+    // module top-level code (class bodies, helpers instantiated on load) can already touch thisLayer
+    this->bindThisLayer (object, this->m_runningModule);
+
     // JS_EvalFunction runs the module body and returns a Promise - for a module with no top-level
     // await this resolves/rejects synchronously, so its state can be checked right away.
     // JS_IsException() on it is always false even if the module threw, since that exception gets
@@ -657,40 +711,316 @@ void ScriptEngine::queueScript (const std::string& key, DynamicValue& currentVal
 
     inserted.first->second.module = module;
 
+    this->bindThisLayer (object, this->m_runningModule);
+
+    // init() and the first update() run from tick(), once the whole scene has been constructed
+}
+
+namespace {
+enum AnimationOp {
+    AnimPlay,
+    AnimPause,
+    AnimStop,
+    AnimGetFrame,
+    AnimSetFrame,
+    AnimIsPlaying,
+    AnimGetRate,
+    AnimSetRate,
+    AnimGetFrameCount,
+    AnimGetName,
+    AnimGetFps,
+};
+
+// backs every method/accessor of the IAnimation handle; func_data is [animation system id, clock id]
+JSValue animation_access (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int systemId = 0;
+    int clockId = 0;
+    JS_ToInt32 (ctx, &systemId, func_data[0]);
+    JS_ToInt32 (ctx, &clockId, func_data[1]);
+
+    auto* system = AnimationSystem::find (systemId);
+    auto* clock = system == nullptr ? nullptr : system->clock (clockId);
+
+    if (clock == nullptr) {
+	return JS_UNDEFINED;
+    }
+
+    double number = 0.0;
+
+    switch (magic) {
+	case AnimPlay:
+	    clock->play ();
+	    return JS_UNDEFINED;
+	case AnimPause:
+	    clock->pause ();
+	    return JS_UNDEFINED;
+	case AnimStop:
+	    clock->stop ();
+	    return JS_UNDEFINED;
+	case AnimGetFrame:
+	    return JS_NewFloat64 (ctx, clock->getFrame ());
+	case AnimSetFrame:
+	    if (argc > 0 && JS_ToFloat64 (ctx, &number, argv[0]) == 0) {
+		clock->setFrame (static_cast<float> (number));
+	    }
+	    return JS_UNDEFINED;
+	case AnimIsPlaying:
+	    return JS_NewBool (ctx, clock->isPlaying ());
+	case AnimGetRate:
+	    return JS_NewFloat64 (ctx, clock->getRate ());
+	case AnimSetRate:
+	    if (argc > 0 && JS_ToFloat64 (ctx, &number, argv[0]) == 0) {
+		clock->setRate (static_cast<float> (number));
+	    }
+	    return JS_UNDEFINED;
+	case AnimGetFrameCount:
+	    return JS_NewFloat64 (ctx, clock->getDefinition ().length);
+	case AnimGetName:
+	    return JS_NewString (ctx, clock->getDefinition ().name.c_str ());
+	case AnimGetFps:
+	    return JS_NewFloat64 (ctx, clock->getDefinition ().fps);
+	default:
+	    return JS_UNDEFINED;
+    }
+}
+
+JSValue makeAnimationHandle (JSContext* ctx, int systemId, int clockId) {
+    JSValue handle = JS_NewObject (ctx);
+    JSValue data[] = { JS_NewInt32 (ctx, systemId), JS_NewInt32 (ctx, clockId) };
+
+    const auto method = [&] (const char* name, int op, int length) {
+	JS_SetPropertyStr (ctx, handle, name, JS_NewCFunctionData (ctx, animation_access, length, op, 2, data));
+    };
+    const auto accessor = [&] (const char* name, int getter, int setter) {
+	JSAtom atom = JS_NewAtom (ctx, name);
+	JSValue get = JS_NewCFunctionData (ctx, animation_access, 0, getter, 2, data);
+	JSValue set = setter < 0 ? JS_UNDEFINED : JS_NewCFunctionData (ctx, animation_access, 1, setter, 2, data);
+	JS_DefinePropertyGetSet (ctx, handle, atom, get, set, JS_PROP_ENUMERABLE);
+	JS_FreeAtom (ctx, atom);
+    };
+
+    method ("play", AnimPlay, 0);
+    method ("pause", AnimPause, 0);
+    method ("stop", AnimStop, 0);
+    method ("getFrame", AnimGetFrame, 0);
+    method ("setFrame", AnimSetFrame, 1);
+    method ("isPlaying", AnimIsPlaying, 0);
+    accessor ("rate", AnimGetRate, AnimSetRate);
+    accessor ("frameCount", AnimGetFrameCount, -1);
+    accessor ("name", AnimGetName, -1);
+    accessor ("fps", AnimGetFps, -1);
+
+    JS_FreeValue (ctx, data[0]);
+    JS_FreeValue (ctx, data[1]);
+
+    return handle;
+}
+
+// thisObject.getAnimation(): func_data is [animation system id, address of the property's DynamicValue]
+JSValue this_object_get_animation (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int systemId = 0;
+    int64_t address = 0;
+    JS_ToInt32 (ctx, &systemId, func_data[0]);
+    JS_ToInt64 (ctx, &address, func_data[1]);
+
+    auto* system = AnimationSystem::find (systemId);
+    auto* value = reinterpret_cast<DynamicValue*> (static_cast<intptr_t> (address));
+    auto* clock = system == nullptr ? nullptr : system->clockOf (*value);
+
+    if (clock == nullptr) {
+	return JS_NULL;
+    }
+
+    return makeAnimationHandle (ctx, systemId, clock->getId ());
+}
+} // namespace
+
+// thisObject.<property name>: lets a listener registered by a property's script write that property
+// from outside (`listener.thisObject['Inner Color'] = color`). func_data is [engine address, value address]
+JSValue this_object_property (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t engineAddress = 0;
+    int64_t valueAddress = 0;
+    JS_ToInt64 (ctx, &engineAddress, func_data[0]);
+    JS_ToInt64 (ctx, &valueAddress, func_data[1]);
+
+    auto* engine = reinterpret_cast<ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+    auto* value = reinterpret_cast<DynamicValue*> (static_cast<intptr_t> (valueAddress));
+
+    if (magic == 0) {
+	return engine->dynamicToJs (*value);
+    }
+
+    if (argc > 0) {
+	engine->assignJsValue (argv[0], *value);
+    }
+
+    return JS_UNDEFINED;
+}
+
+JSValue ScriptEngine::makeThisObject (DynamicValue& value, const std::string& propertyName) {
+    JSValue handle = JS_NewObject (this->m_context);
+    JSValue data[] = {
+	JS_NewInt32 (this->m_context, this->m_animations.getId ()),
+	JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (&value))),
+    };
+
+    JS_SetPropertyStr (
+	this->m_context, handle, "getAnimation",
+	JS_NewCFunctionData (this->m_context, this_object_get_animation, 0, 0, 2, data)
+    );
+    JS_FreeValue (this->m_context, data[0]);
+    JS_FreeValue (this->m_context, data[1]);
+
+    if (!propertyName.empty ()) {
+	JSValue propertyData[] = {
+	    JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (this))),
+	    JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (&value))),
+	};
+	JSAtom atom = JS_NewAtom (this->m_context, propertyName.c_str ());
+
+	JS_DefinePropertyGetSet (
+	    this->m_context, handle, atom,
+	    JS_NewCFunctionData (this->m_context, this_object_property, 0, 0, 2, propertyData),
+	    JS_NewCFunctionData (this->m_context, this_object_property, 1, 1, 2, propertyData), JS_PROP_ENUMERABLE
+	);
+	JS_FreeAtom (this->m_context, atom);
+	JS_FreeValue (this->m_context, propertyData[0]);
+	JS_FreeValue (this->m_context, propertyData[1]);
+    }
+
+    return handle;
+}
+
+void ScriptEngine::bindThisLayer (ScriptableObject& object, LoadedModule* module) {
     JS_SetPropertyStr (this->m_context, this->m_globalThis, "thisLayer", this->m_adapters.object->instantiate (object));
 
+    // thisObject is the property the running script is attached to (what getAnimation() hangs
+    // off), not the layer
+    if (module == nullptr) {
+	JS_SetPropertyStr (
+	    this->m_context, this->m_globalThis, "thisObject", this->m_adapters.object->instantiate (object)
+	);
+	return;
+    }
+
+    if (JS_IsUndefined (module->thisObject)) {
+	module->thisObject = this->makeThisObject (module->value, module->propertyName);
+    }
+
+    JS_SetPropertyStr (
+	this->m_context, this->m_globalThis, "thisObject", JS_DupValue (this->m_context, module->thisObject)
+    );
+}
+
+void ScriptEngine::dispatchAnimationEvents () {
+    for (const auto& event : this->m_animations.takeEvents ()) {
+	const auto* clock = this->m_animations.clock (event.clock);
+
+	if (clock == nullptr) {
+	    continue;
+	}
+
+	for (auto& [key, module] : this->m_scriptModules) {
+	    if (&module.value != &clock->getRootValue () || !module.initialized) {
+		continue;
+	    }
+
+	    this->m_runningModule = &module;
+
+	    if (module.object != nullptr) {
+		this->bindThisLayer (*module.object, &module);
+	    }
+
+	    JSValue eventObject = JS_NewObject (this->m_context);
+	    JS_SetPropertyStr (this->m_context, eventObject, "name", JS_NewString (this->m_context, event.name.c_str ()));
+	    JS_SetPropertyStr (this->m_context, eventObject, "frame", JS_NewFloat64 (this->m_context, event.frame));
+
+	    JSValue args[] = { eventObject, this->dynamicToJs (module.value) };
+	    JSValue result = this->call (module.module, 2, args, "animationEvent");
+
+	    if (JS_IsException (result)) {
+		logJSException (this->m_context, key.c_str ());
+	    } else {
+		// like update(), the handler's return value becomes the property's new value
+		jsToDynamicValue (this->m_context, result, module.value);
+	    }
+
+	    JS_FreeValue (this->m_context, result);
+	    JS_FreeValue (this->m_context, args[0]);
+	    JS_FreeValue (this->m_context, args[1]);
+	    break;
+	}
+    }
+}
+
+void ScriptEngine::initializeModule (const std::string& key, LoadedModule& module) {
+    module.initialized = true;
+
     // init() receives the property's static/base value exactly once, before update() starts being
     // called every tick - scripts commonly stash it (e.g. audio-reactive scale scripts scaling a
     // captured base value) and would otherwise read undefined forever.
-    JSValue initArgs[] = { this->dynamicToJs (currentValue) };
-    JSValue initResult = this->call (module, 1, initArgs, "init");
+    JSValue initArgs[] = { this->dynamicToJs (module.value) };
+    const DynamicValue valueBeforeInit (module.value);
+    JSValue initResult = this->call (module.module, 1, initArgs, "init");
 
     if (JS_IsException (initResult)) {
 	logJSException (this->m_context, key.c_str ());
+    } else if (
+	valueBeforeInit.getType () == module.value.getType () && valueBeforeInit.getVec4 () == module.value.getVec4 ()
+	&& valueBeforeInit.getString () == module.value.getString ()
+    ) {
+	// init() may hand back a different starting value, unless something already moved the property meanwhile
+	jsToDynamicValue (this->m_context, initResult, module.value);
     }
 
     JS_FreeValue (this->m_context, initResult);
     JS_FreeValue (this->m_context, initArgs[0]);
 
-    JSValue args[] = { this->dynamicToJs (currentValue) };
-    JSValue result = this->call (module, 1, args, "update");
-
-    ScopeGuard guard2 ([this, args, result] () {
-	JS_FreeValue (this->m_context, result);
-	JS_FreeValue (this->m_context, args[0]);
-    });
+    // the real engine hands every user property to applyUserProperties() once at load (and only the
+    // changed ones afterward) - scripts like music selectors rely on that first call to start up
+    JSValue userProps = JS_NewObject (this->m_context);
+    for (const auto& [name, property] : this->m_engineObject->getScene ().getUserProperties ()) {
+	JS_SetPropertyStr (this->m_context, userProps, name.c_str (), this->userPropertyToJs (*property));
+    }
+    JSValue applyArgs[] = { userProps };
+    JSValue applyResult = this->call (module.module, 1, applyArgs, "applyUserProperties");
 
-    if (JS_IsException (result)) {
+    if (JS_IsException (applyResult)) {
 	logJSException (this->m_context, key.c_str ());
-	return;
     }
 
-    jsToDynamicValue (this->m_context, result, currentValue);
+    JS_FreeValue (this->m_context, applyResult);
+    JS_FreeValue (this->m_context, userProps);
+
+    if (this->m_mediaSource.getMediaInfo ().available) {
+	this->notifyMediaUpdate (this->m_mediaSource.getMediaInfo (), &module);
+    }
 }
 
 void ScriptEngine::tick () {
     this->m_engineObject->tick ();
 
+    // safe to erase retired modules now, nothing is mid-iteration and no queueScript() is on the stack
+    for (const auto& key : this->m_retiredScriptKeys) {
+	const auto it = this->m_scriptModules.find (key);
+	if (it == this->m_scriptModules.end ()) {
+	    continue;
+	}
+	JS_FreeValue (this->m_context, it->second.module);
+	JS_FreeValue (this->m_context, it->second.thisObject);
+	this->m_scriptModules.erase (it);
+    }
+    this->m_retiredScriptKeys.clear ();
+
+    // long stalls (window dragged, suspend) shouldn't skip whole animations
+    this->m_animations.tick (std::clamp (g_Time - g_TimeLast, 0.0f, 0.25f));
+
     // run any pending notifications
 
     for (auto& [key, module] : this->m_scriptModules) {
@@ -700,9 +1030,11 @@ void ScriptEngine::tick () {
 	// registration in queueScript() - without rebinding it here, every module but the last
 	// registered would see whatever object queueScript() last ran for, not its own layer.
 	if (module.object != nullptr) {
-	    JS_SetPropertyStr (
-		this->m_context, this->m_globalThis, "thisLayer", this->m_adapters.object->instantiate (*module.object)
-	    );
+	    this->bindThisLayer (*module.object, &module);
+	}
+
+	if (!module.initialized) {
+	    this->initializeModule (key, module);
 	}
 
 	JSValue args[] = { this->dynamicToJs (module.value) };
@@ -750,20 +1082,61 @@ void ScriptEngine::tick () {
 
 	jsToDynamicValue (this->m_context, result, module.value);
     }
+
+    this->dispatchAnimationEvents ();
 }
 
-void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media) {
+Media::ThumbnailPalette ScriptEngine::thumbnailPaletteFor (const Media::MediaSource::MediaInfo& media) {
+    if (!media.url.has_value ()) {
+	return {};
+    }
+
+    // every module gets the event, decode the cover once per file
+    if (this->m_paletteUrl == *media.url) {
+	return this->m_palette;
+    }
+
+    this->m_paletteUrl = *media.url;
+    this->m_palette = {};
+
+    std::string path = *media.url;
+
+    if (!path.starts_with ("file://")) {
+	return this->m_palette;
+    }
+
+    path = path.substr (7);
+
+    int width = 0;
+    int height = 0;
+    int channels = 0;
+    auto* pixels = stbi_load (path.c_str (), &width, &height, &channels, 4);
+
+    if (pixels == nullptr) {
+	return this->m_palette;
+    }
+
+    this->m_palette = Media::computeThumbnailPalette (pixels, static_cast<size_t> (width), static_cast<size_t> (height));
+    stbi_image_free (pixels);
+
+    return this->m_palette;
+}
+
+void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media, LoadedModule* only) {
     JSContext* ctx = this->m_context;
 
-    DynamicValue primaryColorValue (glm::vec3 (0.12f, 0.12f, 0.12f));
-    DynamicValue secondaryColorValue (glm::vec3 (0.0f, 0.0f, 0.0f));
-    DynamicValue tertiaryColorValue (glm::vec3 (0.25f, 0.25f, 0.25f));
-    DynamicValue highContrastColorValue (glm::vec3 (1.0f, 1.0f, 1.0f));
+    const auto palette = this->thumbnailPaletteFor (media);
+
+    DynamicValue primaryColorValue (palette.primary);
+    DynamicValue secondaryColorValue (palette.secondary);
+    DynamicValue tertiaryColorValue (palette.tertiary);
+    DynamicValue textColorValue (palette.text);
+    DynamicValue highContrastColorValue (palette.highContrast);
 
-    // TODO: PROCESS THESE COLORS INSTEAD OF HARDCODING THEM
     JSValue primaryColor = this->m_adapters.vec3->instantiate (primaryColorValue, true);
     JSValue secondaryColor = this->m_adapters.vec3->instantiate (secondaryColorValue, true);
     JSValue tertiaryColor = this->m_adapters.vec3->instantiate (tertiaryColorValue, true);
+    JSValue textColor = this->m_adapters.vec3->instantiate (textColorValue, true);
     JSValue highContrastColor = this->m_adapters.vec3->instantiate (highContrastColorValue, true);
 
     JSValue propertiesEvent = JS_NewObject (ctx);
@@ -787,6 +1160,7 @@ void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media
     JS_SetPropertyStr (ctx, mediaThumbnailEvent, "primaryColor", primaryColor);
     JS_SetPropertyStr (ctx, mediaThumbnailEvent, "secondaryColor", secondaryColor);
     JS_SetPropertyStr (ctx, mediaThumbnailEvent, "tertiaryColor", tertiaryColor);
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "textColor", textColor);
     JS_SetPropertyStr (ctx, mediaThumbnailEvent, "highContrastColor", highContrastColor);
 
     JSValue propertiesArgs[] = { propertiesEvent };
@@ -795,6 +1169,11 @@ void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media
     JSValue mediaThumbnailArgs[] = { mediaThumbnailEvent };
 
     for (auto& module : this->m_scriptModules | std::views::values) {
+	// modules that haven't run init() yet get the current media state replayed once they do
+	if (!module.initialized || (only != nullptr && only != &module)) {
+	    continue;
+	}
+
 	JSValue result1 = this->call (module.module, 1, propertiesArgs, "mediaPropertiesChanged");
 	JSValue result2 = this->call (module.module, 1, playbackArgs, "mediaPlaybackChanged");
 	JSValue result3 = this->call (module.module, 1, mediaTimelineArgs, "mediaTimelineChanged");

+ 36 - 2
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "Adapters/VectorAdapter.h"
+#include "AnimationSystem.h"
 #include "ConsoleObject.h"
 #include "EngineObject.h"
 #include "InputObject.h"
@@ -15,10 +16,12 @@
 #include <string>
 #include <unordered_map>
 #include <unordered_set>
+#include <vector>
 
 #include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Media/MediaSource.h"
+#include "WallpaperEngine/Media/ThumbnailPalette.h"
 
 namespace WallpaperEngine::Media {
 class MediaSource;
@@ -32,6 +35,8 @@ class CScene;
 }
 
 namespace WallpaperEngine::Scripting {
+void logJSException (JSContext* ctx, const char* context);
+
 class ScriptPropertiesObject;
 namespace Adapters {
     class ScriptableObjectAdapter;
@@ -50,6 +55,13 @@ public:
 	// Owning layer, so tick() can rebind `thisLayer` to the right object before each
 	// module's update() runs - see ScriptEngine::tick().
 	ScriptableObject* object = nullptr;
+	// init()/applyUserProperties() wait for the first tick() so every layer of the scene already
+	// exists when a script looks its siblings up with thisScene.getLayer()
+	bool initialized = false;
+	// cached `thisObject` handle, see makeThisObject()
+	JSValue thisObject = JS_UNDEFINED;
+	// name of the property the script is attached to ("origin", an effect constant, ...)
+	std::string propertyName;
     };
     struct JSObjectAdapters {
 	std::unique_ptr<Adapters::VectorAdapter<4>> vec4;
@@ -68,6 +80,8 @@ public:
     JSValue getGlobalThis () const { return m_globalThis; }
     LoadedModule* getRunningModule () const { return m_runningModule; }
     JSValue dynamicToJs (DynamicValue& value) const;
+    /** Same as dynamicToJs() but colour properties come out as Vec3 like they do in real scripts, not Vec4 */
+    JSValue userPropertyToJs (Property& property) const;
     // Converts a JS value read from `val` into `target` - the inverse of dynamicToJs(), exposed
     // so exotic property setters (e.g. `thisLayer.origin = ...` from another layer's script) can
     // write through to the real property instead of silently discarding the assignment.
@@ -80,7 +94,15 @@ public:
      * @param currentValue The current value to pass to update()
      * @return The modified value from update(), or a copy of currentValue on error
      */
-    void queueScript (const std::string& key, DynamicValue& currentValue, ScriptableObject& object);
+    void queueScript (
+	const std::string& key, DynamicValue& currentValue, ScriptableObject& object, const std::string& propertyName = {}
+    );
+
+    /** Stops a queued script module (by its queueScript() key) and frees it at the start of the next tick(), for when a later registerProperty() supersedes it */
+    void retireScript (const std::string& key);
+
+    /** 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);
 
     /**
      * Runs a frame tick in the javascript engine. Dispatches any pending events,
@@ -134,6 +156,9 @@ public:
      */
     void destroyLayer (ScriptLayerHandle handle);
 
+    AnimationSystem& getAnimations () { return m_animations; }
+    /** Whether a script module is currently running for this property value */
+    [[nodiscard]] bool hasScript (const DynamicValue& value) const;
     const JSObjectAdapters& getAdapters () const { return m_adapters; }
     const Render::Wallpapers::CScene& getScene () const { return m_scene; }
     const std::map<std::string, std::unique_ptr<Modules::ScriptModule>>& getModules () const { return m_modules; }
@@ -143,7 +168,12 @@ private:
 
     void installBuiltins ();
 
-    void notifyMediaUpdate (const Media::MediaSource::MediaInfo& media);
+    Media::ThumbnailPalette thumbnailPaletteFor (const Media::MediaSource::MediaInfo& media);
+    void notifyMediaUpdate (const Media::MediaSource::MediaInfo& media, LoadedModule* only = nullptr);
+    void initializeModule (const std::string& key, LoadedModule& module);
+    void bindThisLayer (ScriptableObject& object, LoadedModule* module = nullptr);
+    JSValue makeThisObject (DynamicValue& value, const std::string& propertyName);
+    void dispatchAnimationEvents ();
 
     // Installs globalThis.__layers and related helpers. Called lazily.
     void ensureLayerRegistry ();
@@ -160,6 +190,7 @@ private:
 
     std::map<std::string, std::unique_ptr<Modules::ScriptModule>> m_modules = {};
     std::map<std::string, LoadedModule> m_scriptModules = {};
+    std::vector<std::string> m_retiredScriptKeys = {};
 
     LoadedModule* m_runningModule = nullptr;
 
@@ -170,7 +201,10 @@ private:
     Media::MediaSource& m_mediaSource;
     std::function<void ()> m_unregisterMediaUpdateCallback;
     std::function<void ()> m_unregisterAlbumArtUpdateCallback;
+    std::string m_paletteUrl;
+    Media::ThumbnailPalette m_palette;
 
     JSObjectAdapters m_adapters;
+    AnimationSystem m_animations;
 };
 } // namespace WallpaperEngine::Scripting

+ 47 - 6
src/WallpaperEngine/Scripting/ScriptableObject.cpp

@@ -1,6 +1,8 @@
 #include "ScriptableObject.h"
 
 #include "ScriptEngine.h"
+#include "WallpaperEngine/Data/Model/Effect.h"
+#include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 
 #include <cstdint>
@@ -36,18 +38,50 @@ const std::map<std::string, ScriptableObject::PropertyEntry>& ScriptableObject::
     return this->m_properties;
 }
 
-void ScriptableObject::registerProperty (const std::string& name, DynamicValue& value) {
+void ScriptableObject::registerEffectConstants (const std::vector<ImageEffectUniquePtr>& effects) {
+    for (size_t effectIndex = 0; effectIndex < effects.size (); effectIndex++) {
+	const auto& passes = effects[effectIndex]->passOverrides;
+
+	for (size_t passIndex = 0; passIndex < passes.size (); passIndex++) {
+	    const std::string prefix = "fx" + std::to_string (effectIndex) + ".p" + std::to_string (passIndex) + ".";
+
+	    for (const auto& [constant, setting] : passes[passIndex]->constants) {
+		if (!setting->value->getScriptSource ().has_value () && setting->value->getAnimation () == nullptr) {
+		    continue;
+		}
+
+		this->registerProperty (
+		    prefix + constant, *setting->value,
+		    "obj" + std::to_string (this->getId ()) + "/" + prefix, constant
+		);
+	    }
+	}
+    }
+}
+
+void ScriptableObject::registerProperty (
+    const std::string& name, DynamicValue& value, const std::string& animationGroup, const std::string& animationKey
+) {
     if (const auto existing = this->m_properties.find (name); existing != this->m_properties.end ()) {
 	if (&existing->second.value == &value) {
 	    return;
 	}
 
+	this->getScene ().getScriptEngine ().getAnimations ().remove (existing->second.value);
+
 	// A derived class's own field (e.g. CImage's "scale") is overriding the generic
 	// groupScale/groupAngles/groupVisible fallback registered by the base constructor under the
-	// same name. Only stop tracking the stale one here - do NOT unqueue/re-evaluate its
-	// already-queued script: it can still be the engine's "currently running module"
-	// mid-registration, and reusing the same key for a fresh JS_Eval() risks colliding with
-	// QuickJS's own module identity for the one just freed. Fine to leave it ticking in the background.
+	// the base and derived classes parse the same JSON key, so this can be a redundant queue of the same script;
+	// rebind the running module in place when identical so init() does not run twice
+	if (this->getScene ().getScriptEngine ().rebindScript (existing->second.key, value)) {
+	    const std::string key = existing->second.key;
+	    this->m_properties.erase (existing);
+	    this->m_properties.emplace (name, PropertyEntry { .key = key, .value = value });
+	    return;
+	}
+
+	// different script, nothing to rebind - retire the stale lookup
+	this->getScene ().getScriptEngine ().retireScript (existing->second.key);
 	this->m_properties.erase (existing);
     }
 
@@ -58,5 +92,12 @@ void ScriptableObject::registerProperty (const std::string& name, DynamicValue&
 
     const auto inserted = this->m_properties.emplace (name, PropertyEntry { .key = key, .value = value });
 
-    this->getScene ().getScriptEngine ().queueScript (inserted.first->second.key, inserted.first->second.value, *this);
+    this->getScene ().getScriptEngine ().queueScript (
+	inserted.first->second.key, inserted.first->second.value, *this, animationKey.empty () ? name : animationKey
+    );
+
+    this->getScene ().getScriptEngine ().getAnimations ().add (
+	animationGroup.empty () ? "obj" + std::to_string (this->getId ()) : animationGroup,
+	animationKey.empty () ? name : animationKey, value
+    );
 }

+ 11 - 1
src/WallpaperEngine/Scripting/ScriptableObject.h

@@ -25,10 +25,20 @@ public:
 
     const std::map<std::string, PropertyEntry>& getProperties () const;
 
+    /** thisLayer.play()/pause() from scripts, kept here since init() can call it before the derived object exists */
+    void setPlaying (bool playing) { this->m_playing = playing; }
+    [[nodiscard]] bool isPlaying () const { return this->m_playing; }
+
 protected:
-    void registerProperty (const std::string& name, DynamicValue& value);
+    void registerProperty (
+	const std::string& name, DynamicValue& value, const std::string& animationGroup = {},
+	const std::string& animationKey = {}
+    );
+    /** Effect pass constants (Radius, Area, ...) only matter to the script engine when they carry a script or animation */
+    void registerEffectConstants (const std::vector<ImageEffectUniquePtr>& effects);
 
 private:
+    bool m_playing = true;
     std::map<std::string, PropertyEntry> m_properties;
 };
 }

+ 218 - 0
src/WallpaperEngine/Splat/SogLoader.cpp

@@ -0,0 +1,218 @@
+#include "SogLoader.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <memory>
+#include <stdexcept>
+
+#include <nlohmann/json.hpp>
+
+extern "C" {
+#include <libavcodec/avcodec.h>
+#include <libavutil/frame.h>
+#include <libswscale/swscale.h>
+}
+
+namespace WallpaperEngine::Splat {
+namespace {
+    constexpr float SH_C0 = 0.28209479177387814f;
+
+    struct CodecContextDeleter {
+	void operator() (AVCodecContext* ctx) const { avcodec_free_context (&ctx); }
+    };
+    struct PacketDeleter {
+	void operator() (AVPacket* packet) const { av_packet_free (&packet); }
+    };
+    struct FrameDeleter {
+	void operator() (AVFrame* frame) const { av_frame_free (&frame); }
+    };
+    struct SwsDeleter {
+	void operator() (SwsContext* ctx) const { sws_freeContext (ctx); }
+    };
+
+    std::vector<float> readCodebook (const nlohmann::json& section, const char* name) {
+	auto codebook = section.at ("codebook").get<std::vector<float>> ();
+
+	if (codebook.size () != 256) {
+	    throw std::runtime_error (std::string ("SOG ") + name + " codebook must have 256 entries");
+	}
+
+	return codebook;
+    }
+
+    DecodedImage readImage (const FileReader& readFile, const nlohmann::json& section, size_t fileIndex) {
+	const auto& files = section.at ("files");
+
+	if (!files.is_array () || files.size () <= fileIndex) {
+	    throw std::runtime_error ("SOG meta lists too few files for a section");
+	}
+
+	return decodeWebP (readFile (files.at (fileIndex).get<std::string> ()));
+    }
+
+    inline float unpackByte (uint8_t value) { return static_cast<float> (value) / 255.0f; }
+
+    inline uint32_t packColor (float r, float g, float b, float a) {
+	const auto toByte = [] (float value) {
+	    return static_cast<uint32_t> (std::lround (std::clamp (value, 0.0f, 1.0f) * 255.0f));
+	};
+
+	return toByte (r) | (toByte (g) << 8) | (toByte (b) << 16) | (toByte (a) << 24);
+    }
+} // namespace
+
+DecodedImage decodeWebP (const std::string& bytes) {
+    const AVCodec* codec = avcodec_find_decoder (AV_CODEC_ID_WEBP);
+
+    if (codec == nullptr) {
+	throw std::runtime_error ("This FFmpeg build has no WebP decoder");
+    }
+
+    std::unique_ptr<AVCodecContext, CodecContextDeleter> context (avcodec_alloc_context3 (codec));
+
+    if (!context || avcodec_open2 (context.get (), codec, nullptr) < 0) {
+	throw std::runtime_error ("Cannot open the FFmpeg WebP decoder");
+    }
+
+    std::unique_ptr<AVPacket, PacketDeleter> packet (av_packet_alloc ());
+
+    // av_new_packet allocates the input padding the decoders require
+    if (!packet || av_new_packet (packet.get (), static_cast<int> (bytes.size ())) < 0) {
+	throw std::runtime_error ("Cannot allocate a WebP packet");
+    }
+
+    std::memcpy (packet->data, bytes.data (), bytes.size ());
+
+    std::unique_ptr<AVFrame, FrameDeleter> frame (av_frame_alloc ());
+
+    if (avcodec_send_packet (context.get (), packet.get ()) < 0 || avcodec_send_packet (context.get (), nullptr) < 0
+	|| avcodec_receive_frame (context.get (), frame.get ()) < 0) {
+	throw std::runtime_error ("Cannot decode a WebP image");
+    }
+
+    DecodedImage image;
+    image.width = frame->width;
+    image.height = frame->height;
+    image.rgba.resize (static_cast<size_t> (image.width) * image.height * 4);
+
+    std::unique_ptr<SwsContext, SwsDeleter> scaler (sws_getContext (
+	frame->width, frame->height, static_cast<AVPixelFormat> (frame->format), frame->width, frame->height,
+	AV_PIX_FMT_RGBA, SWS_POINT, nullptr, nullptr, nullptr
+    ));
+
+    if (!scaler) {
+	throw std::runtime_error ("Cannot convert the decoded WebP to RGBA");
+    }
+
+    uint8_t* destination[4] = { image.rgba.data (), nullptr, nullptr, nullptr };
+    int destinationStride[4] = { image.width * 4, 0, 0, 0 };
+
+    sws_scale (scaler.get (), frame->data, frame->linesize, 0, frame->height, destination, destinationStride);
+
+    return image;
+}
+
+SplatCloud loadSog (const std::string& metaText, const FileReader& readFile) {
+    const auto meta = nlohmann::json::parse (metaText);
+
+    if (meta.value ("version", 0) != 2) {
+	throw std::runtime_error ("Only SOG version 2 files are supported");
+    }
+
+    const uint32_t count = meta.at ("count").get<uint32_t> ();
+    const auto& means = meta.at ("means");
+    const auto mins = means.at ("mins").get<std::vector<float>> ();
+    const auto maxs = means.at ("maxs").get<std::vector<float>> ();
+
+    if (mins.size () != 3 || maxs.size () != 3) {
+	throw std::runtime_error ("SOG means mins/maxs must have 3 entries");
+    }
+
+    const auto& scalesMeta = meta.at ("scales");
+    const auto& colorMeta = meta.at ("sh0");
+    const auto scaleCodebook = readCodebook (scalesMeta, "scales");
+    const auto colorCodebook = readCodebook (colorMeta, "sh0");
+
+    const DecodedImage meansLow = readImage (readFile, means, 0);
+    const DecodedImage meansHigh = readImage (readFile, means, 1);
+    const DecodedImage quats = readImage (readFile, meta.at ("quats"), 0);
+    const DecodedImage scales = readImage (readFile, scalesMeta, 0);
+    const DecodedImage colors = readImage (readFile, colorMeta, 0);
+
+    for (const DecodedImage* image : { &meansHigh, &quats, &scales, &colors }) {
+	if (image->width != meansLow.width || image->height != meansLow.height) {
+	    throw std::runtime_error ("SOG images do not all have the same dimensions");
+	}
+    }
+
+    if (static_cast<uint64_t> (meansLow.width) * meansLow.height < count) {
+	throw std::runtime_error ("SOG images are too small for the declared splat count");
+    }
+
+    SplatCloud cloud;
+    cloud.count = count;
+    cloud.textureWidth = static_cast<uint32_t> (meansLow.width);
+    cloud.textureHeight = static_cast<uint32_t> ((count + cloud.textureWidth - 1) / cloud.textureWidth);
+
+    const size_t texels = static_cast<size_t> (cloud.textureWidth) * cloud.textureHeight;
+    cloud.centerAndColor.assign (texels * 4, 0.0f);
+    cloud.rotation.assign (texels * 4, 0.0f);
+    cloud.scale.assign (texels * 4, 0.0f);
+
+    const float normalizer = std::sqrt (2.0f);
+
+    for (uint32_t i = 0; i < count; i++) {
+	const uint8_t* low = &meansLow.rgba[static_cast<size_t> (i) * 4];
+	const uint8_t* high = &meansHigh.rgba[static_cast<size_t> (i) * 4];
+	const uint8_t* quat = &quats.rgba[static_cast<size_t> (i) * 4];
+	const uint8_t* scale = &scales.rgba[static_cast<size_t> (i) * 4];
+	const uint8_t* color = &colors.rgba[static_cast<size_t> (i) * 4];
+
+	float* centerAndColor = &cloud.centerAndColor[static_cast<size_t> (i) * 4];
+
+	for (int axis = 0; axis < 3; axis++) {
+	    const float normalized = static_cast<float> (low[axis] + high[axis] * 256) / 65535.0f;
+	    const float logSpace = mins[axis] + (maxs[axis] - mins[axis]) * normalized;
+	    centerAndColor[axis] = std::copysign (std::expm1 (std::fabs (logSpace)), logSpace);
+	}
+
+	const uint32_t packed = packColor (
+	    0.5f + colorCodebook[color[0]] * SH_C0, 0.5f + colorCodebook[color[1]] * SH_C0,
+	    0.5f + colorCodebook[color[2]] * SH_C0, unpackByte (color[3])
+	);
+	std::memcpy (&centerAndColor[3], &packed, sizeof (packed));
+
+	// three quantized components plus the index of the dropped (largest) one, in (w, x, y, z) order
+	const float a = (unpackByte (quat[0]) - 0.5f) * normalizer;
+	const float b = (unpackByte (quat[1]) - 0.5f) * normalizer;
+	const float c = (unpackByte (quat[2]) - 0.5f) * normalizer;
+	const float d = std::sqrt (std::max (0.0f, 1.0f - (a * a + b * b + c * c)));
+
+	float x, y, z, w;
+
+	switch (quat[3] - 252) {
+	    case 0: x = a; y = b; z = c; w = d; break;
+	    case 1: x = d; y = b; z = c; w = a; break;
+	    case 2: x = b; y = d; z = c; w = a; break;
+	    case 3: x = b; y = c; z = d; w = a; break;
+	    default: x = 0.0f; y = 0.0f; z = 0.0f; w = 1.0f; break;
+	}
+
+	const float length = std::sqrt (x * x + y * y + z * z + w * w);
+	const float inverseLength = length > 0.0f ? 1.0f / length : 1.0f;
+	float* rotation = &cloud.rotation[static_cast<size_t> (i) * 4];
+	rotation[0] = x * inverseLength;
+	rotation[1] = y * inverseLength;
+	rotation[2] = z * inverseLength;
+	rotation[3] = w * inverseLength;
+
+	float* scaleOut = &cloud.scale[static_cast<size_t> (i) * 4];
+	scaleOut[0] = std::exp (scaleCodebook[scale[0]]);
+	scaleOut[1] = std::exp (scaleCodebook[scale[1]]);
+	scaleOut[2] = std::exp (scaleCodebook[scale[2]]);
+    }
+
+    return cloud;
+}
+} // namespace WallpaperEngine::Splat

+ 41 - 0
src/WallpaperEngine/Splat/SogLoader.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <string>
+#include <vector>
+
+namespace WallpaperEngine::Splat {
+struct DecodedImage {
+    int width = 0;
+    int height = 0;
+    std::vector<uint8_t> rgba;
+};
+
+// Decodes a WebP file already loaded in memory into tightly packed RGBA8. Goes through the FFmpeg
+// the engine already links for audio, instead of pulling in libwebp as a separate dependency.
+DecodedImage decodeWebP (const std::string& bytes);
+
+// A decoded SOG (PlayCanvas "spatially ordered gaussians") splat cloud, ready to be uploaded as
+// three RGBA32F textures of textureWidth x textureHeight texels, one texel per splat, in the same
+// row-major order the source images use. Positions are in the source photo's own camera frame
+// (x right, y down, z forward), untouched by any viewer transform.
+struct SplatCloud {
+    uint32_t count = 0;
+    uint32_t textureWidth = 0;
+    uint32_t textureHeight = 0;
+
+    // xyz = center, w = the splat's RGBA8 color (r in the low byte) stored as a raw uint32 bit pattern
+    std::vector<float> centerAndColor;
+    // xyzw = unit rotation quaternion
+    std::vector<float> rotation;
+    // xyz = linear (already exponentiated) per-axis scale, w unused
+    std::vector<float> scale;
+};
+
+using FileReader = std::function<std::string (const std::string& name)>;
+
+// meta is the SOG meta.json contents; readFile fetches the image files it references by name.
+// Only SOG version 2 (codebook quantized) is supported. Throws std::runtime_error on bad input.
+SplatCloud loadSog (const std::string& meta, const FileReader& readFile);
+} // namespace WallpaperEngine::Splat

+ 48 - 0
src/WallpaperEngine/Testing/Cases/ScalingModes.cpp

@@ -112,3 +112,51 @@ TEST_CASE ("Changing zoom live is picked up even if the viewport hasn't changed"
 
     CHECK (state.hasChanged ({ 0, 0, 1000, 1000 }, false, 1000, 1000));
 }
+
+TEST_CASE ("setOffset clamps to [-1, 1]") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::CenterUVs, 0);
+
+    state.setOffset (5.0f, -5.0f);
+    CHECK (state.getOffsetX () == Catch::Approx (1.0f));
+    CHECK (state.getOffsetY () == Catch::Approx (-1.0f));
+}
+
+TEST_CASE ("Offset has no effect when nothing is cropped") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+    state.setOffset (1.0f, 1.0f);
+
+    state.updateState ({ 0, 0, 1000, 1000 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (0.0f));
+    CHECK (uend == Catch::Approx (1.0f));
+    CHECK (vstart == Catch::Approx (1.0f));
+    CHECK (vend == Catch::Approx (0.0f));
+}
+
+TEST_CASE ("Offset slides a cropped window toward one edge without changing its size") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::CenterUVs, 0);
+    state.setOffset (1.0f, 0.0f);
+
+    // offsetX=1 should push the centered crop window fully to the right edge, Y untouched
+    state.updateState ({ 0, 0, 500, 500 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (0.5f));
+    CHECK (uend == Catch::Approx (1.0f));
+    CHECK (vend == Catch::Approx (0.25f));
+    CHECK (vstart == Catch::Approx (0.75f));
+}
+
+TEST_CASE ("Changing offset live is picked up even if the viewport hasn't changed") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::CenterUVs, 0);
+
+    state.updateState ({ 0, 0, 500, 500 }, false, 1000, 1000);
+    CHECK_FALSE (state.hasChanged ({ 0, 0, 500, 500 }, false, 1000, 1000));
+
+    state.setOffset (1.0f, 0.0f);
+
+    CHECK (state.hasChanged ({ 0, 0, 500, 500 }, false, 1000, 1000));
+}

+ 22 - 0
src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp

@@ -0,0 +1,22 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Parsers/PropertyParser.h"
+
+using WallpaperEngine::Data::JSON::JSON;
+using WallpaperEngine::Data::Model::DynamicValue;
+using WallpaperEngine::Data::Parsers::PropertyParser;
+
+TEST_CASE ("Text input properties keep their string value without JSON quotes") {
+    const JSON propertyData = {
+	{ "type", "textinput" },
+	{ "text", "Format" },
+	{ "value", "HH:mm" },
+    };
+
+    const auto property = PropertyParser::parse (propertyData, "format");
+
+    REQUIRE (property != nullptr);
+    CHECK (property->getType () == DynamicValue::String);
+    CHECK (property->getString () == "HH:mm");
+}

+ 65 - 0
src/WallpaperEngine/Testing/Cases/ThumbnailPalette.cpp

@@ -0,0 +1,65 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include <vector>
+
+#include "WallpaperEngine/Media/ThumbnailPalette.h"
+
+using WallpaperEngine::Media::computeThumbnailPalette;
+
+namespace {
+void fill (std::vector<uint8_t>& image, size_t width, size_t x0, size_t x1, size_t height, uint8_t r, uint8_t g, uint8_t b) {
+    for (size_t y = 0; y < height; y++) {
+	for (size_t x = x0; x < x1; x++) {
+	    uint8_t* pixel = &image[(y * width + x) * 4];
+	    pixel[0] = r;
+	    pixel[1] = g;
+	    pixel[2] = b;
+	    pixel[3] = 255;
+	}
+    }
+}
+} // namespace
+
+TEST_CASE ("Thumbnail palette orders colors by how much of the cover they cover") {
+    constexpr size_t width = 100;
+    constexpr size_t height = 50;
+    std::vector<uint8_t> image (width * height * 4, 0);
+
+    fill (image, width, 0, 60, height, 200, 30, 30);
+    fill (image, width, 60, 90, height, 30, 30, 200);
+    fill (image, width, 90, 100, height, 30, 200, 30);
+
+    const auto palette = computeThumbnailPalette (image.data (), width, height);
+
+    CHECK (palette.primary.r > 0.7f);
+    CHECK (palette.primary.b < 0.2f);
+    CHECK (palette.secondary.b > 0.7f);
+    CHECK (palette.tertiary.g > 0.7f);
+}
+
+TEST_CASE ("Thumbnail palette picks a readable text color and a contrasting black or white") {
+    constexpr size_t width = 32;
+    constexpr size_t height = 32;
+    std::vector<uint8_t> dark (width * height * 4, 0);
+    std::vector<uint8_t> light (width * height * 4, 0);
+
+    fill (dark, width, 0, width, height, 10, 10, 30);
+    fill (light, width, 0, width, height, 240, 240, 220);
+
+    const auto onDark = computeThumbnailPalette (dark.data (), width, height);
+    const auto onLight = computeThumbnailPalette (light.data (), width, height);
+
+    CHECK (onDark.highContrast.r == 1.0f);
+    CHECK (onLight.highContrast.r == 0.0f);
+    // a flat cover has one color only, secondary and tertiary fall back to it
+    CHECK (onDark.secondary == onDark.primary);
+}
+
+TEST_CASE ("Thumbnail palette ignores transparent pixels and empty images") {
+    std::vector<uint8_t> transparent (16 * 16 * 4, 0);
+
+    const auto fallback = computeThumbnailPalette (transparent.data (), 16, 16);
+    const auto empty = computeThumbnailPalette (nullptr, 0, 0);
+
+    CHECK (fallback.primary == empty.primary);
+}

+ 1 - 1
src/main.cpp

@@ -50,7 +50,7 @@ int main (int argc, char* argv[]) {
 	app = new WallpaperEngine::Application::WallpaperApplication (appContext);
 
 	if (appContext.settings.general.onlyListProperties || appContext.settings.general.onlyListObjects
-	    || appContext.settings.general.onlyListAudioObjects) {
+	    || appContext.settings.general.onlyListAudioObjects || appContext.settings.general.onlyListEffects) {
 	    delete app;
 	    return 0;
 	}

+ 28 - 1
tools/headless_render.sh

@@ -18,6 +18,8 @@
 # Known limitations:
 # - No real D-Bus session bus is assumed - dbus_noop_shim.so (built automatically) patches the
 #   one call path that isn't already null-safe against a missing bus.
+# - The engine is stopped as soon as the screenshot file appears (HEADLESS_RENDER_KEEP_RUNNING=1 disables
+#   that and runs until HEADLESS_RENDER_TIMEOUT, default 60s).
 # - --screenshot-delay is capped at 5000 frames by the engine (ApplicationContext.cpp).
 # - CImage.cpp's puppet/effect diagnostics are one-shot logs that fire on the first draw call,
 #   not a chosen frame.
@@ -53,6 +55,8 @@ if [ ! -S "$XVFB_SOCKET" ]; then
     done
 fi
 
+rm -f "$OUTPUT"
+
 DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11 \
 LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
 LD_PRELOAD="$SHIM_SO" \
@@ -60,7 +64,30 @@ timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \
     --window 0x0x1920x1080 \
     --screenshot "$OUTPUT" \
     --screenshot-delay "${HEADLESS_RENDER_DELAY:-3}" \
-    "$@"
+    "$@" &
+ENGINE_PID=$!
+
+# the engine keeps rendering after the screenshot is taken, so without this the run always lasts
+# the full timeout - stop it as soon as the file is written (set HEADLESS_RENDER_KEEP_RUNNING=1 to
+# let it run until the timeout instead)
+if [ -z "${HEADLESS_RENDER_KEEP_RUNNING:-}" ]; then
+    while kill -0 "$ENGINE_PID" 2>/dev/null; do
+        if [ -s "$OUTPUT" ]; then
+            # wait for the file to stop growing so a half-written PNG isn't cut off
+            prev=-1
+            cur=$(stat -c %s "$OUTPUT")
+            while [ "$cur" != "$prev" ]; do
+                sleep 0.3
+                prev=$cur
+                cur=$(stat -c %s "$OUTPUT")
+            done
+            kill "$ENGINE_PID" 2>/dev/null || true
+            break
+        fi
+        sleep 0.2
+    done
+fi
+wait "$ENGINE_PID" || true
 
 if [ -f "$OUTPUT" ]; then
     echo "Wrote $OUTPUT"

Деякі файли не було показано, через те що забагато файлів було змінено