UwU 1 hónapja
szülő
commit
f076a54543
100 módosított fájl, 1917 hozzáadás és 2168 törlés
  1. 89 246
      README.md
  2. 136 1
      docs/rendering/MDL_FILES.md
  3. 54 22
      src/WallpaperEngine/Application/ApplicationContext.cpp
  4. 15 5
      src/WallpaperEngine/Application/ApplicationContext.h
  5. 95 22
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  6. 12 0
      src/WallpaperEngine/Application/WallpaperApplication.h
  7. 1 2
      src/WallpaperEngine/Assets/AssetLocator.cpp
  8. 4 49
      src/WallpaperEngine/Audio/AudioStream.cpp
  9. 0 3
      src/WallpaperEngine/Audio/Drivers/Detectors/PulseAudioPlayingDetector.cpp
  10. 8 22
      src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
  11. 1 5
      src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp
  12. 4 23
      src/WallpaperEngine/Data/Assets/Texture.h
  13. 0 6
      src/WallpaperEngine/Data/Builders/ColorBuilder.cpp
  14. 0 6
      src/WallpaperEngine/Data/Builders/ColorBuilder.h
  15. 5 32
      src/WallpaperEngine/Data/Builders/VectorBuilder.h
  16. 1 1
      src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp
  17. 2 87
      src/WallpaperEngine/Data/Dumpers/StringPrinter.h
  18. 5 13
      src/WallpaperEngine/Data/JSON.h
  19. 3 4
      src/WallpaperEngine/Data/Model/DynamicValue.cpp
  20. 4 51
      src/WallpaperEngine/Data/Model/DynamicValue.h
  21. 3 12
      src/WallpaperEngine/Data/Model/Effect.h
  22. 1 11
      src/WallpaperEngine/Data/Model/Material.h
  23. 8 8
      src/WallpaperEngine/Data/Model/Model.h
  24. 15 58
      src/WallpaperEngine/Data/Model/Object.h
  25. 2 10
      src/WallpaperEngine/Data/Model/Project.h
  26. 0 1
      src/WallpaperEngine/Data/Model/Property.h
  27. 1 23
      src/WallpaperEngine/Data/Model/Wallpaper.h
  28. 0 3
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
  29. 1 1
      src/WallpaperEngine/Data/Parsers/EffectParser.cpp
  30. 1 1
      src/WallpaperEngine/Data/Parsers/MaterialParser.cpp
  31. 2 0
      src/WallpaperEngine/Data/Parsers/ModelParser.cpp
  32. 21 31
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  33. 1 2
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  34. 1 2
      src/WallpaperEngine/Data/Parsers/ProjectParser.cpp
  35. 1 2
      src/WallpaperEngine/Data/Parsers/PropertyParser.cpp
  36. 9 21
      src/WallpaperEngine/Data/Parsers/TextureParser.cpp
  37. 2 2
      src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp
  38. 1 2
      src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
  39. 0 2
      src/WallpaperEngine/Data/Utils/SFINAE.h
  40. 0 4
      src/WallpaperEngine/FileSystem/Adapters/Package.cpp
  41. 2 5
      src/WallpaperEngine/FileSystem/Container.cpp
  42. 0 2
      src/WallpaperEngine/Input/Drivers/GLFWMouseInput.cpp
  43. 0 20
      src/WallpaperEngine/Input/Drivers/GLFWMouseInput.h
  44. 0 20
      src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h
  45. 0 3
      src/WallpaperEngine/Input/InputContext.h
  46. 0 14
      src/WallpaperEngine/Input/MouseInput.h
  47. 5 12
      src/WallpaperEngine/Logging/Log.h
  48. 0 1
      src/WallpaperEngine/Media/DBusMediaSource.cpp
  49. 1 9
      src/WallpaperEngine/Render/AlbumTexture.cpp
  50. 4 6
      src/WallpaperEngine/Render/CFBO.cpp
  51. 14 20
      src/WallpaperEngine/Render/CTexture.cpp
  52. 2 31
      src/WallpaperEngine/Render/CTexture.h
  53. 5 9
      src/WallpaperEngine/Render/CWallpaper.cpp
  54. 1 2
      src/WallpaperEngine/Render/Camera.cpp
  55. 1 9
      src/WallpaperEngine/Render/Drivers/Detectors/FullScreenDetector.h
  56. 13 98
      src/WallpaperEngine/Render/Drivers/Detectors/KDEWaylandFullScreenDetector.h
  57. 4 12
      src/WallpaperEngine/Render/Drivers/Detectors/X11FullScreenDetector.cpp
  58. 7 21
      src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp
  59. 2 9
      src/WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.cpp
  60. 0 7
      src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h
  61. 3 6
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp
  62. 0 14
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h
  63. 6 23
      src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp
  64. 0 42
      src/WallpaperEngine/Render/Drivers/VideoDriver.h
  65. 2 3
      src/WallpaperEngine/Render/Drivers/VideoFactories.cpp
  66. 1 30
      src/WallpaperEngine/Render/Drivers/VideoFactories.h
  67. 13 24
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  68. 0 4
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
  69. 1 1
      src/WallpaperEngine/Render/FBOProvider.cpp
  70. 0 9
      src/WallpaperEngine/Render/Helpers/ContextAware.h
  71. 770 96
      src/WallpaperEngine/Render/Objects/CImage.cpp
  72. 79 5
      src/WallpaperEngine/Render/Objects/CImage.h
  73. 88 203
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  74. 4 37
      src/WallpaperEngine/Render/Objects/CParticle.h
  75. 0 1
      src/WallpaperEngine/Render/Objects/CRenderable.cpp
  76. 21 5
      src/WallpaperEngine/Render/Objects/CSound.cpp
  77. 4 0
      src/WallpaperEngine/Render/Objects/CSound.h
  78. 78 17
      src/WallpaperEngine/Render/Objects/CText.cpp
  79. 84 72
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  80. 11 4
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  81. 0 3
      src/WallpaperEngine/Render/RenderContext.cpp
  82. 0 5
      src/WallpaperEngine/Render/RenderContext.h
  83. 0 3
      src/WallpaperEngine/Render/Shaders/GLSLContext.h
  84. 0 2
      src/WallpaperEngine/Render/Shaders/Shader.cpp
  85. 1 52
      src/WallpaperEngine/Render/Shaders/Shader.h
  86. 75 85
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  87. 11 110
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  88. 2 9
      src/WallpaperEngine/Render/TextureCache.cpp
  89. 1 17
      src/WallpaperEngine/Render/TextureCache.h
  90. 5 63
      src/WallpaperEngine/Render/TextureProvider.h
  91. 1 5
      src/WallpaperEngine/Render/Utils/NoiseUtils.h
  92. 18 68
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  93. 1 3
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  94. 2 5
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  95. 0 4
      src/WallpaperEngine/Render/Wallpapers/CWeb.cpp
  96. 0 1
      src/WallpaperEngine/Render/Wallpapers/CWeb.h
  97. 46 7
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  98. 19 19
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  99. 0 1
      src/WallpaperEngine/Scripting/ConsoleObject.cpp
  100. 0 4
      src/WallpaperEngine/Scripting/EngineObject.cpp

+ 89 - 246
README.md

@@ -1,43 +1,32 @@
-<p align="center">
-	<a href="https://github.com/Almamu/linux-wallpaperengine/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Almamu/linux-wallpaperengine" /></a>
-    <a href="https://github.com/Almamu/linux-wallpaperengine/actions?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/Almamu/linux-wallpaperengine/cmake.yml?branch=main" /></a>
-    <img src="https://img.shields.io/coderabbit/prs/github/Almamu/linux-wallpaperengine?utm_source=oss&utm_medium=github&utm_campaign=Almamu%2Flinux-wallpaperengine&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" />
-    <a href="https://github.com/Almamu/linux-wallpaperengine/pulse"><img src="https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/Almamu/linux-wallpaperengine/badge?filter=.cpp$,.h$&style=flat&logoColor=white&label=Lines of Code" /></a>
-	<a href="https://www.codefactor.io/repository/github/almamu/linux-wallpaperengine"><img src="https://img.shields.io/codefactor/grade/github/Almamu/linux-wallpaperengine" /></a>
-	<a href="https://github.com/Almamu/linux-wallpaperengine/graphs/commit-activity"><img src="https://img.shields.io/github/commit-activity/m/Almamu/linux-wallpaperengine" /></a>
-	<a href="https://github.com/Almamu/linux-wallpaperengine/graphs/contributors"><img src="https://img.shields.io/github/contributors/Almamu/linux-wallpaperengine" /></a>
-	<a href="https://github.com/Almamu/linux-wallpaperengine/issues"><img src="https://img.shields.io/github/issues-raw/Almamu/linux-wallpaperengine" /></a>
-	<a href="https://github.com/Almamu/linux-wallpaperengine/issues?q=is%3Aissue+is%3Aopen+label%3A%22help%20wanted%22"><img src="https://img.shields.io/github/issues/Almamu/linux-wallpaperengine/help%20wanted?color=green" alt="help wanted"></a>
-    <a href="https://wpengine.alma.mu/"><img src="https://img.shields.io/badge/showcase_gallery-blue" alt="Showcase gallery" /></a>
-    <a href="https://deepwiki.com/Almamu/linux-wallpaperengine"><img src="https://img.shields.io/badge/Deepwiki-Almamu%2Flinux--wallpaperengine-blue?logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK%2FAIi%2BQuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06%2Fuv1saEDv4O3n3dV60RfP947Mm9%2FSQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH%2F%2FPB8mnKqScAhsD0kYP3j%2FYt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY%2F56ebRWeraTjMt%2F00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ%2BfXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB%2FimwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE%2BgO0SsWmPiXB%2Bjikdf6SizrT5qKasx5j8ABbHpFTx%2BvFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa%2BAx283gghmj%2Bvj7feE2KBBRMW3FzOpLOADl0Isb5587h%2FU4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5%2FXFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb%2FvA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU%2B3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26%2FHfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr%2FFGaKiG%2BT%2Bv%2BTQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r%2FcKaoqr%2B27%2FXcrS5UwSMbQAAAABJRU5ErkJggg%3D%3D" alt="DeepWiki documentation" /></a>
-</p>
+# linux-wallpaperengine-kde
 
-# 🖼️ Linux Wallpaper Engine
+A fork of [Almamu/linux-wallpaperengine](https://github.com/Almamu/linux-wallpaperengine), reworked for KDE Plasma and Wayland. It plays Wallpaper Engine (Steam app 431960) live wallpapers on Linux: scene/parallax backgrounds, video (via mpv), and web/HTML backgrounds (via CEF), rendered with a from-scratch OpenGL reimplementation of Wallpaper Engine's renderer.
 
-Bring **Wallpaper Engine**-style live wallpapers to Linux! This project allows you to run animated wallpapers from Steam’s Wallpaper Engine right on your desktop.
+No GUI. This is a command-line tool driven entirely by flags - see Usage below.
 
-> ⚠️ This is an educational project that evolved into a functional OpenGL-based wallpaper engine for Linux. Expect some limitations and quirks!
+## What's different from upstream
 
----
+- Native Wayland output via `wlr-layer-shell`, in addition to the original X11 path.
+- KDE-specific fullscreen-pause detection over the `plasma-shell` protocol, since KWin doesn't implement `wlr-foreign-toplevel-management` like other wlroots compositors. Still experimental (see Limitations).
+- Multi-monitor handling: per-screen backgrounds (`--screen-root`), one wallpaper spanning several monitors (`--screen-span`), per-screen scaling/clamping/zoom/corner color, and Workshop playlists (`--playlist`).
+- A global playback speed multiplier (`--speed`), separate from the FPS cap.
+- More granular audio: restrict sound to a single screen (`--audio-screen`), a separate ambient volume for non-video backgrounds (`--ambient-volume`), per-object sound volume (`--sound-volume`), and a tunable multiplier on audio-reactive properties (`--audio-sensitivity`, with `--list-audio-objects` to see what's wired up).
+- Live hotswap of the running wallpaper via `SIGUSR1`, no process restart.
+- Layer introspection/toggling (`--list-objects`, `--disable-object`, `--enable-object`) for layers the wallpaper author didn't expose as a configurable property.
 
-## 📦 System Requirements
+## Requirements
 
-To compile and run this, you'll need:
-
-- OpenGL 3.3 support
+- OpenGL 3.3
 - CMake
 - LZ4, Zlib
 - SDL2
 - FFmpeg
-- X11 or Wayland
-- Xrandr (for X11)
+- X11 or Wayland (Xrandr on X11)
 - GLFW3, GLEW, GLUT, GLM
 - MPV
 - PulseAudio
 - FFTW3
 
-Install the required dependencies on Ubuntu/Debian-based systems:
-
 ### Ubuntu 22.04
 ```bash
 sudo apt-get update
@@ -50,44 +39,39 @@ sudo apt-get update
 sudo apt-get install build-essential cmake libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
 ```
 
-### Alt linux
-```bash
-sudo epm update
-sudo epm install gcc-c++ make cmake libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel libGL-devel libGLEW-devel freeglut-devel libSDL2-devel liblz4-devel libavcodec-devel libavformat-devel libavutil-devel libswscale-devel libXxf86vm-devel libglm-devel libglfw3-devel libmpv-devel mpv libpulseaudio-devel libpulseaudio libfftw3-devel libpng-devel libffi-devel libswresample-devel libgmpxx-devel
-```
-
-Install the required dependencies on RHEL/Fedora-based systems:
-
 ### Fedora 42
 ```bash
 sudo dnf update
 sudo dnf install gcc g++ cmake libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel mesa-libGL-devel glew-devel freeglut-devel SDL2-devel lz4-devel ffmpeg ffmpeg-free-devel libXxf86vm-devel glm-devel glfw-devel mpv mpv-devel pulseaudio-libs-devel fftw-devel gmp-devel
 ```
 
----
-
-## 🐧 Arch Linux Users
-
-You can install this directly from the AUR using your favorite AUR helper:
-
+### ALT Linux
 ```bash
-yay -S linux-wallpaperengine-git
+sudo epm update
+sudo epm install gcc-c++ make cmake libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel libGL-devel libGLEW-devel freeglut-devel libSDL2-devel liblz4-devel libavcodec-devel libavformat-devel libavutil-devel libswscale-devel libXxf86vm-devel libglm-devel libglfw3-devel libmpv-devel mpv libpulseaudio-devel libpulseaudio libfftw3-devel libpng-devel libffi-devel libswresample-devel libgmpxx-devel
 ```
 
-> This installs the latest development version.
+## Build
 
-**Note:** You’ll still need assets from the official Wallpaper Engine (via Steam). See below for details.
+```bash
+git clone --recurse-submodules <this repo's url>
+cd linux-wallpaperengine-kde
+mkdir build && cd build
+cmake -DCMAKE_BUILD_TYPE='Release' ..
+make
+```
 
----
+The binary and support files end up in `build/output`.
 
-## 🚀 Getting Started
+If you want the "pause on fullscreen" feature on KDE Plasma, install the [KWin Maximize Detector](https://github.com/LS-FCEFyN/Maximize-Detector) script separately, and build with:
 
-### 1. Get Wallpaper Engine Assets
+```bash
+cmake -DCMAKE_BUILD_TYPE='Release' -DENABLE_KDE_EXPERIMENTAL_FEATURES=ON ..
+```
 
-You **must own and install Wallpaper Engine** via Steam. This provides the required assets used by many backgrounds.
+## Assets
 
-Right now the application will automatically detect everything for you as long as the official Wallpaper Engine is installed
-in one of these locations:
+You need Wallpaper Engine installed through Steam - this is where the backgrounds and shared assets come from. The binary looks for it automatically under:
 
 ```
 ~/.steam/steam/steamapps/common
@@ -96,96 +80,21 @@ in one of these locations:
 ~/snap/steam/common/.local/share/Steam/steamapps/common
 ```
 
-> ✅ If Wallpaper Engine is installed in one of these paths, the assets will be detected automatically!
+If it isn't found there, either copy the `assets` folder from Wallpaper Engine's install directory (Steam -> Wallpaper Engine -> Manage -> Browse local files) next to the `linux-wallpaperengine` binary, or point at it directly:
 
----
-
-#### ❗ If Assets Aren’t Found Automatically
-
-If the assets are not detected automatically, you'll see a message like this:
-```
-Cannot find a valid assets folder, resolved to 'assets'
-```
-
-You can copy the `assets` folder manually:
-
-1. In Steam, right-click **Wallpaper Engine** → **Manage** → **Browse local files**
-2. Copy the `assets` folder
-3. Paste it into the same folder where the `linux-wallpaperengine` binary is located (build/output if you followed the build instructions)
-
-Another option is to specify the path manually with the `--assets-dir` option, like this:
 ```bash
 linux-wallpaperengine --assets-dir /path/to/assets
 ```
----
-
-### 2. Build from Source
-
-> ⚠️ If you installed the AUR package mentioned before, you can skip this step.
-
-Clone the repo:
-
-```bash
-git clone --recurse-submodules https://github.com/Almamu/linux-wallpaperengine.git
-cd linux-wallpaperengine
-```
-
-Build it:
-
-```bash
-mkdir build && cd build
-cmake -DCMAKE_BUILD_TYPE='Release' ..
-make
-```
-
-> ⚠️ Note for KDE Plasma users:
-
-KDE Plasma (Wayland) users who intend to use the “pause on fullscreen” feature are required to install an additional script from the [KWin Maximize Detector](https://github.com/LS-FCEFyN/Maximize-Detector) repository. They must also replace:
-
-```bash
-cmake -DCMAKE_BUILD_TYPE='Release' ..
-```
 
-With:
-
-```bash
-cmake -DCMAKE_BUILD_TYPE='Release' -DENABLE_KDE_EXPERIMENTAL_FEATURES=ON ..
-```
-
-
-
-Once the build process is finished, this should create a new `output` folder containing the app and all the required
-support files to run.
-
----
-
-## 🧪 Usage
-
-Basic syntax:
+## Usage
 
 ```bash
 linux-wallpaperengine [options] <background_id or path>
 ```
 
-You can use either:
-- A Steam Workshop ID (e.g. `1845706469`)
-- A path to a background folder
-
----
+The background can be a Steam Workshop ID (`1845706469`) or a path to a background folder.
 
-### What about a GUI?
-
-Implementing a GUI is out of scope for now.
-There's a few developers that decided to focus on this and created their own.
-If you're one of those developers, feel free to open an issue to get your project included here!
-
-- [simple-linux-wallpaperengine-gui](https://github.com/Maxnights/simple-linux-wallpaperengine-gui) by @Maxnights
-- [linux-wallpaper-engine](https://github.com/jagrat7/linux-wallpaper-engine) by @jagrat7
-- [wallpaperengine-gui](https://github.com/MikiDevLog/wallpaperengine-gui) by @MikiDevLog
-- [linux-wallpaperengine-controllfer for Noctalia Shell](https://noctalia.dev/plugins/linux-wallpaperengine-controller/) by @PaloMiku
-- [waypaper](https://github.com/anufrievroman/waypaper) by @anufrievroman
-
-### 🔧 Common Options
+### Options
 
 | Option | Description |
 |--------|-------------|
@@ -196,77 +105,79 @@ If you're one of those developers, feel free to open an issue to get your projec
 | `--no-audio-processing` | Disable audio reactive features |
 | `--fps <val>` | Limit frame rate |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
-| `--screen-root <screen>` | Set as background for specific screen |
+| `--screen-root <screen>` | Set as background for a specific screen |
 | `--screen-span <screen-1>,<screen-2>,...` | Stretch a single wallpaper across multiple screens |
-| `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`/`--screen-span`) |
-| `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, `center`, or `default` |
-| `--zoom <factor>` | Manual zoom layered on top of `--scaling`, e.g. `1.5` (zoom in) or `0.5` (zoom out) |
-| `--clamp <mode>` | Set texture clamping: `clamp` (edge), `border`, `repeat`. Default: `border` |
-| `--corner-color <hex>` | Color shown outside the wallpaper's bounds when `--clamp` is `border` (the default), as `RRGGBB`/`RRGGBBAA`. Default: `000000` (opaque black) |
-| `--assets-dir <path>` | Set custom path for assets |
-| `--screenshot <file>` | Save screenshot (PNG, JPEG, BMP) |
-| `--list-properties` | Show customizable properties of a wallpaper |
-| `--set-property name=value` | Override a specific property |
-| `--list-objects` | List every object/layer a background has, with its id, name and type |
-| `--disable-object <id/name>` | Hide an object/layer (parallax layer, clock, particles, etc), repeatable |
-| `--enable-object <id/name>` | Force an object/layer to show even if the background hides it by default, repeatable |
+| `--bg <id/path>` | Assign a background to a screen (used after `--screen-root`/`--screen-span`) |
+| `--playlist <file>` | Cycle through a Wallpaper Engine playlist from `config.json` |
+| `--scaling <mode>` | `stretch`, `fit`, `fill`, `center`, or `default` |
+| `--zoom <factor>` | Manual zoom on top of `--scaling`, e.g. `1.5` in, `0.5` out |
+| `--clamp <mode>` | Texture clamping: `clamp` (edge), `border`, `repeat`. Default `border` |
+| `--corner-color <hex>` | Color outside the wallpaper's bounds when `--clamp border`, as `RRGGBB`/`RRGGBBAA`. Default `000000` |
+| `--layer <layer>` | Wayland only: `wlr-layer-shell` layer (`background`, `bottom`, `top`, `overlay`) |
+| `--speed <factor>` | Global playback speed multiplier |
+| `--assets-dir <path>` | Custom assets path |
+| `--screenshot <file>` | Save a screenshot (PNG/JPEG/BMP) |
+| `--screenshot-delay <n>` | Frames to wait before the screenshot (default 5) |
+| `--list-properties` | List a wallpaper's customizable properties |
+| `--set-property name=value` | Override a property |
+| `--list-objects` | List every object/layer, with id, name and type |
+| `--disable-object <id/name>` | Hide an object/layer, repeatable |
+| `--enable-object <id/name>` | Force an object/layer to show, repeatable |
+| `--disable-particles` | Disable particles |
 | `--disable-mouse` | Disable mouse interaction |
-| `--disable-parallax` | Disable parallax effect on backgrounds that support it |
-| `--no-fullscreen-pause` | Prevent pausing while fullscreen apps are running |
-| `--fullscreen-pause-only-active` | Wayland only: pause only when a fullscreen window is active |
-| `--fullscreen-pause-ignore-appid <val>` | Wayland only: ignore fullscreen windows whose app_id contains `<val>` (repeatable) |
+| `--disable-parallax` | Disable parallax |
+| `--no-fullscreen-pause` | Don't pause while an app is fullscreen |
+| `--fullscreen-pause-only-active` | Wayland only: pause only when the fullscreen window is active |
+| `--fullscreen-pause-ignore-appid <val>` | Wayland only: ignore fullscreen windows whose app_id contains `<val>`, repeatable |
+| `--audio-screen <screen>` | Only this screen's background produces audio |
+| `--ambient-volume <val>` | Separate volume for non-video backgrounds; video keeps using `--volume` |
+| `--sound-volume <id/name>=<val>` | Per-object volume (0-1), repeatable, `*` for unmatched objects |
+| `--audio-sensitivity <id/name>=<mult>` | Scale an object's audio-reactive swing; `0` disables it, `1` is default; repeatable, `*` for unmatched objects |
+| `--list-audio-objects` | List objects whose script reacts to music |
 
----
+### Examples
 
-### 💡 Examples
-
-#### Run a background by ID
+Run by Workshop ID:
 ```bash
 linux-wallpaperengine 1845706469
 ```
 
-#### Run a background from a folder
+Run from a local folder:
 ```bash
 linux-wallpaperengine ~/backgrounds/1845706469/
 ```
 
-#### Assign backgrounds to screens with scaling
+Different background per monitor:
 ```bash
 linux-wallpaperengine \
   --scaling stretch --screen-root eDP-1 --bg 2667198601 \
   --scaling fill --screen-root HDMI-1 --bg 2667198602
 ```
 
-#### Stretch one wallpaper across multiple monitors
+One wallpaper across multiple monitors:
 ```bash
-linux-wallpaperengine \
-  --scaling fill --screen-span HDMI-A-1,DP-2,DP-3 --bg 1845706469
+linux-wallpaperengine --scaling fill --screen-span HDMI-A-1,DP-2,DP-3 --bg 1845706469
 ```
 
-#### Run in a window
+Windowed:
 ```bash
 linux-wallpaperengine --window 0x0x1280x720 1845706469
 ```
 
-#### Limit FPS to save power
+Capped FPS:
 ```bash
 linux-wallpaperengine --fps 30 1845706469
 ```
 
-#### Take a screenshot
+Screenshot (useful as input for pywal-style color extraction):
 ```bash
 linux-wallpaperengine --screenshot ~/wallpaper.png 1845706469
 ```
 
-This can be useful as output for pywal or other color systems that use images as basis to generate a set of colors
-to apply to your system.
-
-#### View and change properties
+Inspect and override properties:
 ```bash
 linux-wallpaperengine --list-properties 2370927443
 ```
-
-The output includes all the relevant information for each of the different properties:
 ```
 barcount - slider
 	Description: Bar Count
@@ -278,53 +189,12 @@ barcount - slider
 bloom - boolean
 	Description: Bloom
 	Value: 0
-frequency - combolist
-	Description: Frequency
-	Value: 2
-		Posible values:
-		16 -> 1
-		32 -> 2
-		64 -> 3
-
-owl - boolean
-	Description: Owl
-	Value: 0
-rain - boolean
-	Description: Rain
-	Value: 1
-schemecolor - color
-	Description: ui_browse_properties_scheme_color
-	R: 0.14902 G: 0.23137 B: 0.4 A: 1
-visualizer - boolean
-	Description: <hr>Add Visualizer<hr>
-	Value: 1
-visualizercolor - color
-	Description: Bar Color
-	R: 0.12549 G: 0.215686 B: 0.352941 A: 1
-visualizeropacity - slider
-	Description: Bar Opacity
-	Value: 1
-	Minimum value: 0
-	Maximum value: 1
-	Step: 0.1
-
-visualizerwidth - slider
-	Description: Bar Spacing
-	Value: 0.25
-	Minimum value: 0
-	Maximum value: 0.5
-	Step: 0.01
-```
-
-Any of these values can be modified with the --set-property switch. Say you want to enable the bloom in this background, you would do so like this:
 ```
+```bash
 linux-wallpaperengine --set-property bloom=1 2370927443
 ```
 
-#### Disable or enable individual objects/layers
-
-Not every wallpaper exposes a property for each layer it has (e.g. a parallax background layer or a clock).
-`--list-objects` shows every object the background is made of, with its id, name and type:
+List and toggle layers (not every wallpaper exposes a property for each one):
 ```bash
 linux-wallpaperengine --list-objects 2370927443
 ```
@@ -334,52 +204,25 @@ Objects for default:
   2 - Clock (text)
   3 - Rain (particle)
 ```
-
-Use `--disable-object` (or `--enable-object`) with either the id or the name to toggle a specific layer,
-regardless of whether the wallpaper's author exposed a property for it. Both switches can be repeated:
-```
+```bash
 linux-wallpaperengine --disable-object Clock --disable-object 3 2370927443
 ```
 
----
-
-## 🧪 Wayland & X11 Support
+## Wayland and X11
 
-- **Wayland**: Works with compositors that support `wlr-layer-shell-unstable`. Uses `xdg-output-unstable-v1` for accurate monitor positioning (required for `--screen-span`).
-- **X11**: Requires XRandr. Use `--screen-root <screen_name>` (as shown in `xrandr`).
-
-> ⚠ For X11 users: Currently doesn't work if a compositor or desktop environment (e.g. GNOME, KDE, Nautilus) is drawing the background.
-
----
-
-## 🌈 Example Backgrounds
-
-![example1](docs/images/example.gif)
-![example2](docs/images/example2.gif)
-
-Want to see more examples of backgrounds that work? Head over to the [project's website](https://wpengine.alma.mu/#showcase)
-
-## 🪲 Common issues
-### Black screen when setting as screen's background
-This can be caused by a few different things depending on your environment and setup.
-
-### X11
-Common symptom of a compositor drawing to the background which prevents Wallpaper Engine from being properly visible.
-The only solution currently is disabling the compositor so Wallpaper Engine can properly draw on the screen
-
-### NVIDIA
-Some users have had issues with GLFW initialization and other OpenGL errors. These are generally something that's
-worth reporting in the issues. Sometimes adding this variable when running Wallpaper Engine helps and/or solves
-the issue:
-```bash
-__GL_THREADED_OPTIMIZATIONS=0 linux-wallpaperengine
-```
+- Wayland: needs a compositor with `wlr-layer-shell-unstable` and `xdg-output-unstable-v1` (the latter for accurate monitor positioning with `--screen-span`).
+- X11: needs XRandr; target monitors with `--screen-root <name>` as reported by `xrandr`. Doesn't work if something else (GNOME, KDE, Nautilus) is already drawing the desktop background/compositing it.
 
-We'll be looking at improving this in the future, but for now it can be a useful workaround.
+## Limitations
 
----
+- Light and VolumeLight scene objects are parsed but not rendered - wallpapers that depend on them for lighting will look different from the Windows original.
+- Passthrough image effects aren't implemented.
+- Text objects can't sample the background behind them (no copybackground-style effects on `Text`).
+- KDE fullscreen-pause detection is experimental and requires the separate KWin Maximize Detector script plus `-DENABLE_KDE_EXPERIMENTAL_FEATURES=ON`.
+- On X11, a compositor or DE drawing its own background will block the wallpaper. Disabling the compositor is currently the only fix.
+- Some NVIDIA setups hit GLFW/OpenGL init failures; try `__GL_THREADED_OPTIMIZATIONS=0 linux-wallpaperengine` if you run into this.
 
-## 🙏 Special Thanks
+## Credits
 
-- [RePKG](https://github.com/notscuffed/repkg) – for texture flag insights
-- [RenderDoc](https://github.com/baldurk/renderdoc) – the best OpenGL debugger out there!
+- [RePKG](https://github.com/notscuffed/repkg) - texture flag insights
+- [RenderDoc](https://github.com/baldurk/renderdoc) - OpenGL debugging

+ 136 - 1
docs/rendering/MDL_FILES.md

@@ -101,4 +101,139 @@ MDLFILE file;
 // mdlv => vertices information
 // mdls => skinning information
 // mdla => animation information
-```
+```
+
+## Vertex blend indices/weights
+
+`VERTEX.blendindices`/`blendweight` are always the 32 bytes immediately before the trailing UV pair, regardless of
+the overall vertex stride:
+
+```
+blendIndicesOffset = vertexStride - 40   // 4x DWORD, values are bone indices into the MDLS bone array
+blendWeightsOffset = vertexStride - 24   // 4x float, sums to 1.0
+uvOffset            = vertexStride - 8
+```
+
+The "narrow" (52-byte) stride is exactly `position(12) + blendindices(16) + blendweight(16) + uv(8)`. The "wide"
+(80-byte, and similar) stride inserts a normal + tangent4 between position and the blend data:
+`position(12) + normal(12) + tangent4(16) + blendindices(16) + blendweight(16) + uv(8)`. This is not a second bone
+slot as previously assumed - puppet meshes only ever carry one set of up to 4 blend influences.
+
+## MDLS bone array (first array only)
+
+```
+CHAR   header[9]        // "MDLSxxxx\0"
+DWORD  mdlaOffset        // absolute file offset where MDLA begins (matches the MDLA search marker)
+DWORD  boneCount
+BONE   bones[boneCount]
+```
+```
+typedef struct {
+    BYTE   tmp;
+    DWORD  type;
+    INT32  parent;        // index into bones[], -1 for a root bone
+    DWORD  matrixByteLength;  // always 64 (16 floats) in every sample seen
+    FLOAT  bindLocalMatrix[16]; // row-major 4x4, row-vector convention (matches the engine's `mul(v,M)=v*M`
+                                 // HLSL convention elsewhere); rotation/scale block has always been identity in
+                                 // every sample seen, translation sits in row 3 (elements 12/13 = tx/ty, 14 = tz=0)
+    CHAR   name[];         // null-terminated, always empty in every sample seen
+} BONE;
+```
+
+A second array (`numberOfBones` more entries, previously named `BONE2ENTRY` in this doc) follows and has *not*
+been decoded - it isn't needed for skinning since the per-bone inverse-bind matrix can be derived directly from
+the local bind matrices above by walking the parent chain. `mdlaOffset` gives an exact byte length for the whole
+MDLS section, so the second array can safely be skipped wholesale rather than parsed.
+
+## MDLA (baked animation clips)
+
+```
+CHAR   header[9]     // "MDLAxxxx\0"
+DWORD  A             // close to file size; exact meaning/use unclear, not needed for playback
+DWORD  clipCount
+DWORD  C             // for single-clip files this equals the scene.json object's
+                      // animationlayers[].animation id; ambiguous with >1 clip - match clips by
+                      // name instead (scene.json's animationlayers[].name), not by this field
+DWORD  D             // always 0 in every sample seen
+CLIP   clips[clipCount]
+```
+```
+typedef struct {
+    CHAR   name[];        // null-terminated, matches scene.json's animationlayers[].name
+    CHAR   mode[];         // null-terminated, "loop" observed; other values unconfirmed
+    FLOAT  fps;
+    DWORD  frameCount;
+    DWORD  flag;           // always 0 in every sample seen
+    DWORD  boneCount;      // should match the MDLS bone count
+    BONETRACK tracks[boneCount];  // same order as the MDLS bone array
+} CLIP;
+```
+```
+typedef struct {
+    DWORD  zero;           // always 0 in every sample seen
+    DWORD  trackByteLength; // == (frameCount + 1) * 36
+    SAMPLE samples[frameCount + 1]; // fixed-rate, one every 1/fps seconds; sample[frameCount] ==
+                                     // sample[0] for a "loop" clip (closes the cycle exactly)
+} BONETRACK;
+```
+```
+typedef struct {
+    FLOAT  posX, posY, posZ;
+    FLOAT  rotX, rotY, rotZ;  // Euler angles in radians; rotX/rotY are 0 in every 2D puppet sample seen
+    FLOAT  scaleX, scaleY, scaleZ;
+} SAMPLE;
+```
+
+There is a small (a few dozen to ~900 bytes, seen to vary per clip), still-undecoded trailer between one clip's
+last bone track and the next clip's name string (or EOF for the last clip). It doesn't matter for playback of a
+single matched clip; a parser reading multiple clips sequentially needs to resynchronize past it (e.g. by
+scanning forward for the next plausible clip header) rather than assuming a fixed size.
+
+## MDAT (attachment points)
+
+An optional section between MDLS and MDLA, present on puppets that have named points other objects can follow
+via scene.json's `"attachment"` field (e.g. an orb or a weapon rigidly stuck to a hand bone). When absent, the
+MDLS jump field goes straight to MDLA instead.
+
+```
+CHAR   header[9]     // "MDATxxxx\0"
+DWORD  mdlaOffset     // absolute file offset where MDLA begins, same role as MDLS's jump field
+WORD   pointCount
+WORD   boneIndex0     // point[0]'s bone index - see note below, this is NOT padding
+POINT  points[pointCount]
+```
+```
+typedef struct {
+    CHAR   name[];         // null-terminated, matches scene.json's "attachment" value on a child object
+    FLOAT  localMatrix[16]; // row-major 4x4, same convention as BONE.bindLocalMatrix - the point's transform
+                             // relative to this point's bone index
+    WORD   nextBoneIndex;   // bone index for points[i+1], NOT for this point - see note below. Absent
+                             // entirely on the last point (nothing follows it to index)
+} POINT;
+```
+
+The bone index for `points[i]` is written one slot early: `points[0]`'s index is the WORD immediately after
+`pointCount` (previously assumed to be an unused/padding field), and `points[i]`'s own trailing WORD is actually
+`points[i+1]`'s index. The last point has no trailing WORD at all. Reading a WORD after every point's matrix
+(including the last) overruns two bytes past the end of the MDAT section, landing on the next section's magic
+bytes (`MDLA`/`MDAT` read back as a byte-swapped "implausible" bone index); the shifted reading above consumes
+the section's declared byte length exactly, confirmed against `mikasaback_puppet.mdl` (2 points, "hair" and
+"eye" - MDAT section byte length matches exactly under the shifted reading, both resolved bone indices fall
+comfortably inside that puppet's 73-bone skeleton, and the "hair" attachment already renders correctly using its
+half of this scheme).
+
+The point's live offset from its bind pose is `(animatedBoneWorld[boneIndex] * localMatrix).translation -
+(bindBoneWorld[boneIndex] * localMatrix).translation`; a child object with a matching `"attachment"` adds that
+offset to its own local origin instead of (or on top of) following a plain `"parent"` relationship.
+
+Only cross-checked against `mikasaback_puppet.mdl` and (for the general shape) `spiritblossomahribase_puppet.mdl`,
+which declares 3 points where the 3rd previously failed every plausibility check under the old (unshifted)
+reading - worth re-checking against the shifted reading above, since that file hasn't been re-verified since this
+was found. Parsing still stops at the first implausible entry and keeps whatever points were read successfully
+rather than risking garbage data.
+
+Reverse-engineered by cross-referencing multiple real puppet `.mdl` files pulled from Workshop content
+(`ahriarm_puppet.mdl`, `ahritailbottom_puppet.mdl`, `spiritblossomahribase_puppet.mdl`) - matching decoded bind
+translations/keyframe values against scene.json, and validating that decoded byte offsets exactly consume every
+byte up to the known MDLA/EOF boundary. No official documentation or decompilation was available for this part
+of the format.

+ 54 - 22
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -277,9 +277,8 @@ std::optional<bool> ApplicationContext::resolveObjectVisibility (int id, const s
 }
 
 std::optional<float> ApplicationContext::resolveAudioSensitivity (int id, const std::string& name) const {
-    // "*" is a wildcard default applied to every audio-reactive object with no more specific
-    // match - checked last so a specific id/name override always wins over it, regardless of the
-    // (alphabetically ordered) iteration order of the underlying map.
+    // "*" is a wildcard default, checked last so a specific id/name match always wins
+    // regardless of the map's (alphabetically ordered) iteration order.
     std::optional<float> wildcard;
 
     for (const auto& [token, multiplier] : this->settings.general.audioSensitivity) {
@@ -296,6 +295,24 @@ std::optional<float> ApplicationContext::resolveAudioSensitivity (int id, const
     return wildcard;
 }
 
+std::optional<float> ApplicationContext::resolveSoundVolume (int id, const std::string& name) const {
+    // "*" is a wildcard default, checked last so a specific id/name match always wins (same rule as resolveAudioSensitivity).
+    std::optional<float> wildcard;
+
+    for (const auto& [token, volume] : this->settings.general.soundVolume) {
+	if (token == "*") {
+	    wildcard = volume;
+	    continue;
+	}
+
+	if (matchesObjectToken (token, id, name)) {
+	    return volume;
+	}
+    }
+
+    return wildcard;
+}
+
 void ApplicationContext::loadSettingsFromArgv () {
     std::string lastScreen;
 
@@ -313,9 +330,9 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    }
 	});
 
-    // Internal, not advertised in --help. Appended to CEF subprocess re-execs so they see the
-    // *current* (possibly hotswapped) background instead of the "background id" positional's
-    // launch-time value. Registered last so it takes precedence.
+    // Internal, hidden: appended to CEF subprocess re-execs so they see the current (possibly
+    // hotswapped) background instead of the launch-time "background id" positional. Registered
+    // last so it takes precedence.
     backgroundGroup.add_argument ("--current-background")
 	.default_value ("")
 	.hidden ()
@@ -325,8 +342,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    }
 	});
 
-    // Internal, not advertised in --help: marks a self-re-exec as a disposable CEF host for one
-    // Web wallpaper.
+    // Internal, hidden: marks a self-re-exec as a disposable CEF host for one Web wallpaper.
     backgroundGroup.add_argument ("--web-host")
 	.flag ()
 	.hidden ()
@@ -434,11 +450,9 @@ void ApplicationContext::loadSettingsFromArgv () {
 		    != this->settings.general.screenBackgrounds.end ()) {
 		    sLog.exception ("--screen-span: screen '", screen, "' is already configured individually");
 		}
-		// reject duplicates within this group
 		if (std::find (group.screens.begin (), group.screens.end (), screen) != group.screens.end ()) {
 		    sLog.exception ("--screen-span: duplicate screen name '", screen, "'");
 		}
-		// reject screens already claimed by another span group
 		for (const auto& existing : this->settings.general.spanGroups) {
 		    if (std::find (existing.screens.begin (), existing.screens.end (), screen)
 			!= existing.screens.end ()) {
@@ -455,9 +469,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    group.scaling = this->settings.render.window.scalingMode;
 	    group.clamp = this->settings.render.window.clamp;
 	    this->settings.general.spanGroups.push_back (std::move (group));
-	    // set lastScreen to a synthetic name so --bg/--scaling/--clamp can target this group
+	    // synthetic "span:" name lets --bg/--scaling/--clamp target this group
 	    lastScreen = "span:" + value;
-	    // register the synthetic name in screenBackgrounds so the rest of the pipeline sees it
 	    this->settings.general.screenBackgrounds[lastScreen] = "";
 	})
 	.append ();
@@ -465,9 +478,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.help ("After --screen-root or --screen-span, specifies the background to use")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	    this->settings.general.screenBackgrounds[lastScreen] = translateBackground (value);
-	    // set the default background to the last one used
 	    this->settings.general.defaultBackground = translateBackground (value);
-	    // if this targets a span group, update the group's background too
 	    if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
 		this->settings.general.spanGroups.back ().background = translateBackground (value);
 	    }
@@ -515,7 +526,6 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenScalings[lastScreen] = mode;
-		// also update span group if targeting one
 		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
 		    this->settings.general.spanGroups.back ().scaling = mode;
 		}
@@ -545,7 +555,6 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenClamps[lastScreen] = flags;
-		// also update span group if targeting one
 		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
 		    this->settings.general.spanGroups.back ().clamp = flags;
 		}
@@ -572,7 +581,6 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenZooms[lastScreen] = zoom;
-		// also update span group if targeting one
 		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
 		    this->settings.general.spanGroups.back ().zoom = zoom;
 		}
@@ -598,7 +606,6 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenCornerColors[lastScreen] = *color;
-		// also update span group if targeting one
 		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
 		    this->settings.general.spanGroups.back ().cornerColor = *color;
 		}
@@ -780,7 +787,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.action ([this] (const std::string& value) -> void {
 	    const std::string::size_type equals = value.find ('=');
 
-	    // properties without value are treated as booleans for now
+	    // properties without a value are treated as booleans for now
 	    if (equals == std::string::npos) {
 		this->settings.general.properties[value] = "1";
 	    } else {
@@ -836,6 +843,29 @@ void ApplicationContext::loadSettingsFromArgv () {
 	})
 	.append ();
 
+    configurationGroup.add_argument ("--sound-volume")
+	.help ("Sets a Sound object's own volume (0-1), independent of the global volume - lets a wallpaper with "
+	       "several alternate music tracks play only one, matched by id or name. Use \"*\" as the id to set a "
+	       "default for every Sound object with no more specific match. Format: <id-or-name-or-*>=<volume>. Can "
+	       "be repeated")
+	.action ([this] (const std::string& value) -> void {
+	    const std::string::size_type equals = value.find ('=');
+
+	    if (equals == std::string::npos) {
+		sLog.exception ("--sound-volume expects <id-or-name>=<volume>, got '" + value + "'");
+	    }
+
+	    const std::string target = value.substr (0, equals);
+	    const std::string volumeStr = value.substr (equals + 1);
+
+	    try {
+		this->settings.general.soundVolume[target] = std::stof (volumeStr);
+	    } catch (const std::exception&) {
+		sLog.exception ("--sound-volume: '" + volumeStr + "' is not a valid number");
+	    }
+	})
+	.append ();
+
     auto& debuggingGroup = program.add_group ("Debugging options");
 
     debuggingGroup.add_argument ("-z", "--dump-structure")
@@ -845,8 +875,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 
     debuggingGroup.add_argument ("--render-debug")
 	.help (
-	    "Scene render debug mode: base-only, no-solid-final, pass-log, object=<id>, skip-object=<id>, or "
-	    "skip-effect=<id>. Can be repeated."
+	    "Scene render debug mode: base-only, no-solid-final, pass-log, no-puppet-animation, object=<id>, "
+	    "skip-object=<id>, or skip-effect=<id>. Can be repeated."
 	)
 	.action ([this] (const std::string& value) -> void {
 	    const auto parseDebugId = [&value] (const std::string& prefix) -> std::optional<int> {
@@ -866,6 +896,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 		this->settings.render.debug.noSolidFinal = true;
 	    } else if (value == "pass-log") {
 		this->settings.render.debug.passLog = true;
+	    } else if (value == "no-puppet-animation") {
+		this->settings.render.debug.noPuppetAnimation = true;
 	    } else if (value.rfind ("object=", 0) == 0) {
 		this->settings.render.debug.objectFilter = parseDebugId ("object=");
 	    } else if (value.rfind ("skip-object=", 0) == 0) {
@@ -934,7 +966,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 	this->settings.screenshot.delay
 	    = std::max<uint32_t> (0, std::min<uint32_t> (this->settings.screenshot.delay, 5));
 
-	// use std::cout on this in case logging is disabled, this way it's easy to look at what is running
+	// std::cout directly, in case logging is disabled, so this is still visible
 	std::stringbuf buffer;
 	std::ostream bufferStream (&buffer);
 

+ 15 - 5
src/WallpaperEngine/Application/ApplicationContext.h

@@ -42,6 +42,15 @@ public:
      */
     [[nodiscard]] std::optional<float> resolveAudioSensitivity (int id, const std::string& name) const;
 
+    /**
+     * Resolves the --sound-volume override for a Sound object, matching id or name, or falling
+     * back to a "*" wildcard default if one was given and no more specific match exists.
+     *
+     * @return the configured volume (0-1), or nullopt if this object has no override (use the
+     *         wallpaper's original volume unchanged)
+     */
+    [[nodiscard]] std::optional<float> resolveSoundVolume (int id, const std::string& name) const;
+
     enum WINDOW_MODE {
 	NORMAL_WINDOW = 0,
 	/** Draw to the window server desktop */
@@ -88,7 +97,6 @@ public:
     };
 
     struct {
-	// General settings
 	struct {
 	    bool onlyListProperties;
 	    bool onlyListObjects;
@@ -101,6 +109,8 @@ public:
 	    std::vector<std::string> enabledObjects;
 	    /** 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 */
+	    std::map<std::string, float> soundVolume;
 	    std::filesystem::path assets;
 	    /** Background to load (provided as the final argument) as fallback for multi-screen setups */
 	    std::filesystem::path defaultBackground;
@@ -128,7 +138,6 @@ public:
 	    uint32_t webHostHeight;
 	} general;
 
-	// Render settings
 	struct {
 	    WINDOW_MODE mode;
 	    int maximumFPS;
@@ -149,6 +158,8 @@ public:
 		bool baseOnly;
 		bool noSolidFinal;
 		bool passLog;
+		/** Renders puppets in their static bind pose, ignoring animation clips entirely */
+		bool noPuppetAnimation;
 		std::optional<int> objectFilter;
 		std::vector<int> skipObjects;
 		std::vector<int> skipEffects;
@@ -169,7 +180,6 @@ public:
 	    } wayland;
 	} render;
 
-	// Audio settings
 	struct {
 	    bool enabled;
 	    /** 0-128 */
@@ -182,13 +192,11 @@ public:
 	    std::optional<int> ambientVolume;
 	} audio;
 
-	// Mouse input settings
 	struct {
 	    bool enabled;
 	    bool disableparallax;
 	} mouse;
 
-	// Screenshot settings
 	struct {
 	    bool take;
 	    /** In frames, not seconds */
@@ -204,6 +212,7 @@ public:
             .disabledObjects = {},
             .enabledObjects = {},
             .audioSensitivity = {},
+            .soundVolume = {},
             .assets = "",
             .defaultBackground = "",
             .screenBackgrounds = {},
@@ -231,6 +240,7 @@ public:
                 .baseOnly = false,
                 .noSolidFinal = false,
 	                .passLog = false,
+	                .noPuppetAnimation = false,
 	                .objectFilter = std::nullopt,
 	                .skipObjects = {},
 	                .skipEffects = {},

+ 95 - 22
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -61,11 +61,25 @@ using namespace WallpaperEngine::FileSystem;
 void CustomGLDebugCallback (
     GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar* message, const void* userParam
 ) {
-    if (severity != GL_DEBUG_SEVERITY_HIGH) {
+    // Widened from HIGH-only to catch MEDIUM/LOW too, while chasing the reload-corruption bug
+    // (bloom-enabled scenes going black after an in-process wallpaper reload) - HIGH-only produced
+    // nothing even with the driver's debug output confirmed reachable, so the failure isn't a GL
+    // API validity error at all; it's a logic bug somewhere in scene reconstruction. Safe to narrow
+    // back to HIGH-only once that's found, but low-severity output costs little in the meantime.
+    if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) {
 	return;
     }
 
-    sLog.error ("OpenGL error: ", message, ", type: ", type, ", id: ", id);
+    // libmpv's own internal renderer (not our code - shows up as libmpv.so/libgallium.so frames in
+    // the call stack below) reuses a GL_STATIC_DRAW buffer with glBufferSubData every frame during
+    // video playback, which the driver flags as a performance hint, not a correctness problem. It
+    // fires on every single frame of every video wallpaper, drowning out anything else in this
+    // (already widened) severity range.
+    if (type == GL_DEBUG_TYPE_PERFORMANCE) {
+	return;
+    }
+
+    sLog.error ("OpenGL error: ", message, ", type: ", type, ", id: ", id, ", severity: ", severity);
 
     std::vector<WallpaperEngine::Debugging::CallStack::CallInfo> callInfo;
 
@@ -109,6 +123,7 @@ WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_con
 
     this->setupProperties ();
     this->setupAudioSensitivity ();
+    this->setupSoundVolume ();
     this->listObjects ();
     this->listAudioObjects ();
     this->setupBrowser ();
@@ -236,7 +251,6 @@ void WallpaperApplication::loadBackgrounds () {
 	if (screen.rfind ("span:", 0) == 0) {
 	    continue;
 	}
-	// screens with no path should use the default
 	if (path.empty ()) {
 	    this->m_backgrounds[screen] = this->loadBackground (this->m_context.settings.general.defaultBackground);
 	} else {
@@ -244,7 +258,6 @@ void WallpaperApplication::loadBackgrounds () {
 	}
     }
 
-    // Load one background per span group
     for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
 	if (spanGroup.screens.empty ()) {
 	    continue;
@@ -255,7 +268,7 @@ void WallpaperApplication::loadBackgrounds () {
 	    bgPath = this->m_context.settings.general.defaultBackground;
 	}
 
-	// use the first screen's name as the group key for the loaded project
+	// "span:" + first screen's name is the convention used as the group key
 	const std::string groupKey = "span:" + spanGroup.screens.front ();
 	this->m_backgrounds[groupKey] = this->loadBackground (bgPath);
     }
@@ -265,9 +278,7 @@ ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
     auto container = this->setupAssetLocator (bg);
     auto json = WallpaperEngine::Data::JSON::JSON::parse (container->readString ("project.json"));
 
-    // when a background is loaded, reset the screenshot variables
-    // this allows taking screenshots after a background changes
-    // useful for playlists
+    // 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;
 
@@ -558,6 +569,9 @@ struct HotswapRequest {
     /** True once at least one "audio-sensitivity=id=multiplier" line was seen */
     bool audioSensitivityProvided = false;
     std::map<std::string, std::string> audioSensitivity;
+    /** True once at least one "sound-volume=id=volume" line was seen */
+    bool soundVolumeProvided = false;
+    std::map<std::string, std::string> soundVolume;
 };
 
 std::string trimHotswapToken (const std::string& value) {
@@ -657,6 +671,15 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 		request.audioSensitivityProvided = true;
 		request.audioSensitivity[value.substr (0, sensSeparator)] = value.substr (sensSeparator + 1);
 	    }
+	} else if (key == "sound-volume") {
+	    const auto volSeparator = value.find ('=');
+
+	    if (volSeparator == std::string::npos) {
+		sLog.error ("Hotswap: ignoring malformed sound-volume line: ", value);
+	    } else {
+		request.soundVolumeProvided = true;
+		request.soundVolume[value.substr (0, volSeparator)] = value.substr (volSeparator + 1);
+	    }
 	} else {
 	    sLog.error ("Hotswap: ignoring unknown control file key: ", key);
 	}
@@ -687,7 +710,7 @@ void WallpaperApplication::checkHotswapRequest () {
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.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.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
 	return;
     }
@@ -728,6 +751,10 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applyAmbientVolumeHotswap (*request.ambientVolume);
     }
 
+    if (request.soundVolumeProvided) {
+	this->applySoundVolumeHotswap (request.soundVolume);
+    }
+
     if (request.layersProvided) {
 	this->m_context.settings.general.disabledObjects = request.disabledObjects;
 	this->m_context.settings.general.enabledObjects = request.enabledObjects;
@@ -781,6 +808,7 @@ void WallpaperApplication::checkHotswapRequest () {
 
 	    this->setupPropertiesForProject (*project);
 	    this->setupAudioSensitivityForProject (*project);
+	    this->setupSoundVolumeForProject (*project);
 
 	    background = std::move (project);
 
@@ -1299,6 +1327,57 @@ void WallpaperApplication::setupAudioSensitivity () {
     }
 }
 
+void WallpaperApplication::setupSoundVolumeForProject (const Project& project) const {
+    if (!project.wallpaper->is<Scene> ()) {
+	return;
+    }
+
+    const auto scene = project.wallpaper->as<Scene> ();
+
+    for (const auto& object : scene->objects) {
+	if (!object->is<Sound> ()) {
+	    continue;
+	}
+
+	const auto* sound = object->as<Sound> ();
+	const auto volume = this->m_context.resolveSoundVolume (object->id, object->name);
+
+	if (!volume.has_value () || !sound->volume || !sound->volume->value) {
+	    continue;
+	}
+
+	sound->volume->value->update (std::clamp (volume.value (), 0.0f, 1.0f), DynamicValue::UpdateSource::User);
+
+	sLog.debug ("Applying sound volume ", volume.value (), " to ", object->id, " - ", object->name);
+    }
+}
+
+void WallpaperApplication::setupSoundVolume () {
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->setupSoundVolumeForProject (*info);
+    }
+}
+
+void WallpaperApplication::applySoundVolumeHotswap (const std::map<std::string, std::string>& targets) {
+    for (const auto& [target, value] : targets) {
+	try {
+	    this->m_context.settings.general.soundVolume[target] = std::stof (value);
+	} catch (const std::exception&) {
+	    sLog.error ("Hotswap: ignoring invalid sound-volume value: ", value);
+	}
+    }
+
+    // Sound objects already read their own DynamicValue live every frame (see
+    // CSound::applyEffectiveVolume), so re-resolving and pushing the new value straight into the
+    // currently loaded projects' live objects is enough - no reload needed, unlike properties/
+    // audio-sensitivity which are baked into the scene graph at parse time.
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->setupSoundVolumeForProject (*info);
+    }
+
+    sLog.out ("Hotswap: applied sound volume live");
+}
+
 void WallpaperApplication::setupBrowser () {
     // The main engine process never hosts CEF directly - CEF only supports one
     // CefInitialize()/CefShutdown() pair per process, so a process that might later need to stop
@@ -1470,7 +1549,6 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 	// this is more reliable than the default framebuffer on some drivers (NVIDIA/Wayland)
 	glBindFramebuffer (GL_FRAMEBUFFER, wallpaper->getWallpaperFramebuffer ());
 
-	// ensure rendering is complete before reading
 	glFinish ();
 
 	const int readWidth = wallpaper->getWidth ();
@@ -1513,16 +1591,14 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 	auto* bitmap = new uint8_t[width * height * 3] { 0 };
 
 	for (const auto& capture : captures) {
-	    // copy pixels to bitmap, sampling from the UV-defined region
+	    // sample the bitmap from the UV-defined visible region
 	    for (int y = 0; y < capture.vpHeight; y++) {
 		for (int x = 0; x < capture.vpWidth; x++) {
-		    // interpolate within the UV range to get source coordinates
 		    const float u
 			= capture.ustart + (static_cast<float> (x) / capture.vpWidth) * (capture.uend - capture.ustart);
 		    const float v = capture.vstart
 			+ (static_cast<float> (y) / capture.vpHeight) * (capture.vend - capture.vstart);
 
-		    // convert UV to pixel coordinates in the source buffer
 		    const int srcX = std::clamp (static_cast<int> (u * capture.readWidth), 0, capture.readWidth - 1);
 		    const int srcY = std::clamp (static_cast<int> (v * capture.readHeight), 0, capture.readHeight - 1);
 		    const int srcIdx = (srcY * capture.readWidth + srcX) * 3;
@@ -1577,7 +1653,6 @@ void WallpaperApplication::setupOutput () {
 }
 
 void WallpaperApplication::setupAudio () {
-    // ensure audioprocessing is required by any background, and we have it enabled
     const bool audioProcessingRequired = std::ranges::any_of (
 	this->m_backgrounds, [] (const std::pair<const std::string, ProjectUniquePtr>& pair) -> bool {
 	    return pair.second->supportsAudioProcessing;
@@ -1646,7 +1721,7 @@ void WallpaperApplication::prepareOutputs () {
 	    continue;
 	}
 
-	// Compute the bounding box of all viewports in this span group
+	// bounding box of all viewports in this span group
 	const auto& viewports = m_renderContext->getOutput ().getViewports ();
 	int minX = INT_MAX, minY = INT_MAX, maxX = INT_MIN, maxY = INT_MIN;
 	bool anyFound = false;
@@ -1685,7 +1760,6 @@ void WallpaperApplication::prepareOutputs () {
 	WallpaperEngine::Render::CWallpaper::SpanInfo spanInfo;
 	spanInfo.totalBounds = { minX, minY, maxX - minX, maxY - minY };
 
-	// Create one shared wallpaper with the span group's scaling mode
 	auto sharedWallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
 	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, this->resolveScreenBackgroundPath (groupKey),
 	    spanGroup.scaling, spanGroup.clamp, glm::ivec2 { maxX - minX, maxY - minY }
@@ -1697,7 +1771,6 @@ void WallpaperApplication::prepareOutputs () {
 	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));
 	shared->setSpanInfo (spanInfo);
 
-	// Register the same wallpaper for each screen in the span group
 	for (const auto& screenName : spanGroup.screens) {
 	    m_renderContext->setWallpaper (screenName, shared);
 	}
@@ -1707,10 +1780,12 @@ void WallpaperApplication::prepareOutputs () {
 }
 
 void WallpaperApplication::setupOpenGLDebugging () {
-#if !NDEBUG
+    // Not gated behind NDEBUG: GL_DEBUG_OUTPUT has near-zero cost when nothing goes wrong, and a
+    // Release build silently swallowing driver errors is exactly what's masking the in-process
+    // reload corruption bug - see CustomGLDebugCallback() above and the "layer toggle blackens the
+    // wallpaper" investigation.
     glDebugMessageCallback (CustomGLDebugCallback, nullptr);
     glEnable (GL_DEBUG_OUTPUT_SYNCHRONOUS);
-#endif
 }
 
 void WallpaperApplication::setup () {
@@ -1803,9 +1878,7 @@ void WallpaperApplication::render () {
 	}
 
 #if DEMOMODE
-	// wait for a full render cycle before actually starting
-	// this gives some extra time for video and web decoders to set themselves up
-	// because of size changes
+	// wait a full render cycle before starting, giving video/web decoders time to set up
 	if (m_videoDriver->getFrameCounter () > (uint32_t)this->m_context.settings.render.maximumFPS) {
 	    if (!initialized) {
 		width = this->m_renderContext->getWallpapers ().begin ()->second->getWidth ();

+ 12 - 0
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -75,6 +75,10 @@ private:
     void listAudioObjects () const;
     void listAudioObjectsForProject (const std::string& background, const Project& project) const;
 
+    /** Applies --sound-volume overrides for every loaded background */
+    void setupSoundVolume ();
+    void setupSoundVolumeForProject (const Project& project) const;
+
     void setupBrowser ();
     void setupOutput ();
     void setupAudio ();
@@ -157,6 +161,14 @@ private:
     /** Pushes a new --ambient-volume (0-128) live, then re-applies audio policy to every currently rendered wallpaper */
     void applyAmbientVolumeHotswap (const std::string& value);
 
+    /**
+     * Pushes --sound-volume overrides (id-or-name -> 0-1 volume) live to every Sound object in the
+     * currently loaded projects, without reloading - unlike --set-property/--audio-sensitivity,
+     * Sound objects already re-read their own volume every frame (see CSound::render()), so this
+     * is a pure live setter.
+     */
+    void applySoundVolumeHotswap (const std::map<std::string, std::string>& targets);
+
     /**
      * Recomputes and pushes CWallpaper::setAudioPolicy() to every currently rendered wallpaper
      * based on settings.audio.audioScreen/ambientVolume. Span-group screens share a single

+ 1 - 2
src/WallpaperEngine/Assets/AssetLocator.cpp

@@ -10,7 +10,7 @@ std::string AssetLocator::shader (const std::filesystem::path& filename) const {
     try {
 	std::filesystem::path shader = filename;
 
-	// detect workshop shaders and check if there's a
+	// workshop shaders may have a zcompat replacement under zcompat/scene/shaders/<id>/<file>
 	if (auto it = shader.begin (); *it++ == "workshop") {
 	    const std::filesystem::path workshopId = *it++;
 
@@ -19,7 +19,6 @@ std::string AssetLocator::shader (const std::filesystem::path& filename) const {
 
 		try {
 		    shader = std::filesystem::path ("zcompat") / "scene" / "shaders" / workshopId / shaderfile;
-		    // replace the old path with the new one
 		    std::string contents = this->m_filesystem->readString (shader);
 
 		    sLog.out ("Replaced ", filename, " with compat ", shader);

+ 4 - 49
src/WallpaperEngine/Audio/AudioStream.cpp

@@ -4,8 +4,6 @@
 #include <cmath>
 #include <iostream>
 
-// maximum size of the queue to prevent reading too much data
-
 using namespace WallpaperEngine::Audio;
 
 int audio_read_thread (void* arg) {
@@ -33,7 +31,6 @@ int audio_read_thread (void* arg) {
 	ret = av_read_frame (stream->getFormatContext (), packet);
 
 	if (ret == AVERROR_EOF) {
-	    // seek to the beginning of the file again
 	    avformat_seek_file (stream->getFormatContext (), stream->getAudioStream (), 0, 0, 0, ~AVSEEK_FLAG_FRAME);
 	    avcodec_flush_buffers (stream->getContext ());
 
@@ -53,7 +50,6 @@ int audio_read_thread (void* arg) {
 	}
     }
 
-    // stop the audio too just in case
     SDL_DestroyMutex (waitMutex);
 
     return 0;
@@ -62,7 +58,6 @@ int audio_read_thread (void* arg) {
 static int audio_read_data_callback (void* streamarg, uint8_t* buffer, int buffer_size) {
     const auto stream = static_cast<AudioStream*> (streamarg);
 
-    // check if we're at eof and return the right value
     if (stream->getBuffer ()->eof ()) {
 	return AVERROR_EOF;
     }
@@ -73,7 +68,6 @@ static int audio_read_data_callback (void* streamarg, uint8_t* buffer, int buffe
 	return AVERROR_INVALIDDATA;
     }
 
-    // return read bytes only
     return stream->getBuffer ()->gcount ();
 }
 
@@ -111,7 +105,6 @@ AudioStream::AudioStream (AudioContext& context, const std::string& filename) :
 }
 
 AudioStream::AudioStream (AudioContext& context, const ReadStreamSharedPtr& buffer) : m_audioContext (context) {
-    // setup a custom context first
     this->m_formatContext = avformat_alloc_context ();
 
     if (this->m_formatContext == nullptr) {
@@ -120,7 +113,6 @@ AudioStream::AudioStream (AudioContext& context, const ReadStreamSharedPtr& buff
 
     this->m_buffer = buffer;
 
-    // setup custom io for it
     this->m_formatContext->pb = avio_alloc_context (
 	static_cast<uint8_t*> (av_malloc (4096)), 4096, 0, this, &audio_read_data_callback, nullptr,
 	&audio_seek_data_callback
@@ -130,7 +122,6 @@ AudioStream::AudioStream (AudioContext& context, const ReadStreamSharedPtr& buff
 	sLog.exception ("Cannot create avio context");
     }
 
-    // continue the normal load procedure
     this->loadCustomContent ();
 }
 
@@ -140,11 +131,9 @@ AudioStream::AudioStream (AudioContext& audioContext, AVCodecContext* context) :
 }
 
 AudioStream::~AudioStream () {
-    // stop the audio
     this->stop ();
 
     if (this->m_audioThread != nullptr) {
-	// wait for the thread to finish
 	SDL_WaitThread (this->m_audioThread, nullptr);
     }
 
@@ -190,7 +179,6 @@ void AudioStream::loadCustomContent (const char* filename) {
 	sLog.exception ("Cannot determine file format: ", filename);
     }
 
-    // find the audio stream
     for (unsigned int i = 0; i < this->m_formatContext->nb_streams; i++) {
 	if (this->m_formatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO
 	    && this->m_audioStream == NO_AUDIO_STREAM) {
@@ -202,7 +190,6 @@ void AudioStream::loadCustomContent (const char* filename) {
 	sLog.exception ("Cannot find an audio stream in file ", filename);
     }
 
-    // get the decoder for it and alloc the required context
     const AVCodec* aCodec
 	= avcodec_find_decoder (this->m_formatContext->streams[this->m_audioStream]->codecpar->codec_id);
 
@@ -210,7 +197,6 @@ void AudioStream::loadCustomContent (const char* filename) {
 	sLog.exception ("Cannot initialize audio decoder for file: ", filename);
     }
 
-    // alocate context
     AVCodecContext* avCodecContext = avcodec_alloc_context3 (aCodec);
 
     if (avcodec_parameters_to_context (avCodecContext, this->m_formatContext->streams[this->m_audioStream]->codecpar)
@@ -218,21 +204,17 @@ void AudioStream::loadCustomContent (const char* filename) {
 	sLog.exception ("Cannot initialize audio decoder parameters");
     }
 
-    // finally open
     avcodec_open2 (avCodecContext, aCodec, nullptr);
 
-    // initialize default data
     this->m_context = avCodecContext;
     this->m_queue = new PacketQueue;
 
     this->initialize ();
 
-    // initialize an SDL thread to read the file
     this->m_audioThread = SDL_CreateThread (audio_read_thread, filename, this);
 }
 
 void AudioStream::initialize () {
-// allocate the FIFO buffer
 #if FF_API_FIFO_OLD_API
     this->m_queue->packetList = av_fifo_alloc (sizeof (MyAVPacketList));
 #else
@@ -242,7 +224,6 @@ void AudioStream::initialize () {
 #if FF_API_OLD_CHANNEL_LAYOUT
     int64_t out_channel_layout;
 
-    // set output audio channels based on the input audio channels
     switch (this->m_audioContext.getChannels ()) {
 	case 1:
 	    out_channel_layout = AV_CH_LAYOUT_MONO;
@@ -255,7 +236,6 @@ void AudioStream::initialize () {
 	    break;
     }
 
-    // initialize swrctx
     this->m_swrctx = swr_alloc_set_opts (
 	nullptr, out_channel_layout, this->m_audioContext.getFormat (), this->m_audioContext.getSampleRate (),
 	this->getContext ()->channel_layout, this->getContext ()->sample_fmt, this->getContext ()->sample_rate, 0,
@@ -265,7 +245,6 @@ void AudioStream::initialize () {
     AVChannelLayout out_channel_layout;
     int64_t out_channel_mask;
 
-    // set output audio channels based on the input audio channels
     switch (this->m_audioContext.getChannels ()) {
 	case 1:
 	    out_channel_mask = AV_CH_LAYOUT_MONO;
@@ -292,12 +271,10 @@ void AudioStream::initialize () {
 	sLog.exception ("Cannot initialize swrctx for audio resampling");
     }
 
-    // initialize the context
     if (swr_init (this->m_swrctx) < 0) {
 	sLog.exception ("Failed to initialize the resampling context.");
     }
 
-    // setup the queue information
     this->m_queue->mutex = SDL_CreateMutex ();
     this->m_queue->cond = SDL_CreateCond ();
     this->m_queue->wait = SDL_CreateCond ();
@@ -316,7 +293,6 @@ void AudioStream::initialize () {
 }
 
 void AudioStream::queuePacket (AVPacket* pkt) {
-    // clone the packet
     AVPacket* clone = av_packet_alloc ();
 
     if (clone == nullptr) {
@@ -347,7 +323,6 @@ bool AudioStream::doQueue (AVPacket* pkt) {
 
     av_fifo_generic_write (this->m_queue->packetList, &entry, sizeof (entry), nullptr);
 #else
-    // write the entry if possible
     if (av_fifo_write (this->m_queue->packetList, &entry, 1) < 0) {
 	return false;
     }
@@ -384,13 +359,11 @@ void AudioStream::dequeuePacket () {
 	    this->m_queue->size -= entry.packet->size + sizeof (entry);
 	    this->m_queue->duration -= entry.packet->duration;
 
-	    // move the reference and free the old one
 	    av_packet_move_ref (this->m_decodePacket, entry.packet);
 	    av_packet_free (&entry.packet);
 	    break;
 	}
 
-	// make the thread wait if nothing was available
 	SDL_CondWait (this->m_queue->cond, this->m_queue->mutex);
     }
 
@@ -448,7 +421,6 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
     uint8_t** resampled_data = nullptr;
     int resampled_data_size;
 
-    // retrieve number of audio samples (per channel)
     const int in_nb_samples = this->m_decodeFrame->nb_samples;
     if (in_nb_samples <= 0) {
 	sLog.error ("in_nb_samples error.");
@@ -459,17 +431,14 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 	in_nb_samples, this->m_audioContext.getSampleRate (), this->getContext ()->sample_rate, AV_ROUND_UP
     );
 
-    // check rescaling was successful
     if (max_out_nb_samples <= 0) {
 	sLog.error ("av_rescale_rnd error.");
 	return -1;
     }
 
-    // get number of output audio channels
 #if FF_API_OLD_CHANNEL_LAYOUT
     int64_t out_channel_layout;
 
-    // set output audio channels based on the input audio channels
     switch (this->m_audioContext.getChannels ()) {
 	case 1:
 	    out_channel_layout = AV_CH_LAYOUT_MONO;
@@ -484,10 +453,9 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 
     out_nb_channels = av_get_channel_layout_nb_channels (out_channel_layout);
 #else
-    // this must be the channel count swr_convert() below will actually write out, not the input
-    // file's channel count - a mono sound resampled to a stereo driver output would otherwise get
-    // a buffer sized for one channel while swr_convert() (configured via m_audioContext.getChannels()
-    // in initialize()) writes two, overflowing it
+    // Must be the channel count swr_convert() below will actually write, not the input file's
+    // channel count - a mono sound resampled to a stereo driver output would otherwise get a
+    // buffer sized for one channel while swr_convert() writes two, overflowing it.
     out_nb_channels = this->m_audioContext.getChannels ();
 #endif
     ret = av_samples_alloc_array_and_samples (
@@ -499,28 +467,24 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 	return -1;
     }
 
-    // retrieve output samples number taking into account the progressive delay
+    // account for the progressive resampling delay
     out_nb_samples = av_rescale_rnd (
 	swr_get_delay (this->m_swrctx, this->getContext ()->sample_rate) + in_nb_samples,
 	this->m_audioContext.getSampleRate (), this->getContext ()->sample_rate, AV_ROUND_UP
     );
 
-    // check output samples number was correctly retrieved
     if (out_nb_samples <= 0) {
 	sLog.error ("av_rescale_rnd error");
 	return -1;
     }
 
     if (out_nb_samples > max_out_nb_samples) {
-	// free memory block and set pointer to NULL
 	av_freep (&resampled_data[0]);
 
-	// Allocate a samples buffer for out_nb_samples samples
 	ret = av_samples_alloc (
 	    resampled_data, &out_linesize, out_nb_channels, out_nb_samples, this->m_audioContext.getFormat (), 1
 	);
 
-	// check samples buffer correctly allocated
 	if (ret < 0) {
 	    sLog.error ("av_samples_alloc failed.");
 	    return -1;
@@ -529,34 +493,27 @@ int AudioStream::resampleAudio (uint8_t* out_buf, const int out_size) {
 	max_out_nb_samples = out_nb_samples;
     }
 
-    // do the actual audio data resampling
     ret = swr_convert (
 	this->m_swrctx, resampled_data, max_out_nb_samples, const_cast<const uint8_t**> (this->m_decodeFrame->data),
 	this->m_decodeFrame->nb_samples
     );
 
-    // check audio conversion was successful
     if (ret < 0) {
 	sLog.error ("swr_convert_error.");
 	return -1;
     }
 
-    // Get the required buffer size for the given audio parameters
     resampled_data_size
 	= av_samples_get_buffer_size (&out_linesize, out_nb_channels, ret, this->m_audioContext.getFormat (), 1);
 
-    // check audio buffer size
     if (resampled_data_size < 0) {
 	sLog.error ("av_samples_get_buffer_size error.");
 	return -1;
     }
 
-    // copy the resampled data to the output buffer up to out_size bytes
     memcpy (out_buf, resampled_data[0], std::min (resampled_data_size, out_size));
 
-    // memory cleanup
     if (resampled_data) {
-	// free memory block and set pointer to NULL
 	av_freep (&resampled_data[0]);
     }
 
@@ -595,14 +552,12 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
 	    int data_size = 0;
 
 	    if (got_frame) {
-		// audio resampling
 		data_size = this->resampleAudio (audioBuffer, bufferSize);
 	    }
 	    if (data_size <= 0) {
 		// no data found, keep waiting
 		continue;
 	    }
-	    // some data was found
 	    return data_size;
 	}
 

+ 0 - 3
src/WallpaperEngine/Audio/Drivers/Detectors/PulseAudioPlayingDetector.cpp

@@ -15,7 +15,6 @@ void sinkInputInfoCallback (pa_context* context, const pa_sink_input_info* info,
 	return;
     }
 
-    // get processid
     const char* value = pa_proplist_gets (info->proplist, PA_PROP_APPLICATION_PROCESS_ID);
 
     if (value && strtol (value, nullptr, 10) != getpid () && pa_cvolume_avg (&info->volume) != PA_VOLUME_MUTED) {
@@ -68,10 +67,8 @@ void PulseAudioPlayingDetector::update () {
 	return this->setIsPlaying (true);
     }
 
-    // reset playing state
     this->setIsPlaying (false);
 
-    // start discovery of sinks
     pa_operation* op = pa_context_get_server_info (this->m_context, defaultSinkInfoCallback, this);
 
     // wait until all the operations are done

+ 8 - 22
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -74,9 +74,7 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 		const size_t startOfLastBuffer = std::max (
 		    dataToCopy + (numberOfFullBuffers - 1) * WAVE_BUFFER_SIZE, currentSize - WAVE_BUFFER_SIZE
 		);
-		// copy directly into the final buffer
 		memcpy (recorder->audioBuffer, &data[startOfLastBuffer], WAVE_BUFFER_SIZE * sizeof (uint8_t));
-		// copy whatever is left to the read/write buffer
 		recorder->currentWritePointer = currentSize - startOfLastBuffer - WAVE_BUFFER_SIZE;
 		memcpy (
 		    recorder->audioBufferTmp, &data[startOfLastBuffer + WAVE_BUFFER_SIZE],
@@ -88,14 +86,11 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 		uint8_t* tmp = recorder->audioBuffer;
 		recorder->audioBuffer = recorder->audioBufferTmp;
 		recorder->audioBufferTmp = tmp;
-		// reset write pointer
 		recorder->currentWritePointer = 0;
 	    }
 
-	    // signal a new frame is ready
 	    recorder->fullFrameReady = true;
 	} else {
-	    // copy over available data to the tmp buffer and everything should be set
 	    memcpy (&recorder->audioBufferTmp[recorder->currentWritePointer], data, dataToCopy * sizeof (uint8_t));
 	    recorder->currentWritePointer += dataToCopy;
 	}
@@ -130,7 +125,6 @@ void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userd
     std::string monitor_name (info->default_sink_name);
     monitor_name += ".monitor";
 
-    // setup latency
     pa_buffer_attr attr {};
 
     // 10 = latency msecs, 750 = max msecs to store
@@ -158,9 +152,7 @@ void pa_context_notify_cb (pa_context* ctx, void* userdata) {
     switch (pa_context_get_state (ctx)) {
 	case PA_CONTEXT_READY:
 	    {
-		// set callback
 		pa_context_set_subscribe_callback (ctx, pa_context_subscribe_cb, userdata);
-		// set events mask and enable event callback.
 		pa_operation* o = pa_context_subscribe (
 		    ctx, static_cast<pa_subscription_mask_t> (PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE),
 		    nullptr, nullptr
@@ -211,11 +203,10 @@ PulseAudioPlaybackRecorder::PulseAudioPlaybackRecorder () :
     }
 
     // Capture used to be pumped from the render loop (pa_mainloop_iterate() once per frame via
-    // update()), which meant a slow frame - a GPU/compositor stall, a heavy shader pass, anything
-    // that blocks the render thread - stalled audio capture along with it. PulseAudio/PipeWire then
-    // has to force-drop the backlog once its buffer overflows, so the wallpaper "catches up" all at
-    // once instead of reacting smoothly. Capture now runs on its own thread so it keeps draining
-    // regardless of what rendering is doing.
+    // update()), so a slow frame - a GPU/compositor stall, a heavy shader pass - stalled capture
+    // along with it. PulseAudio/PipeWire then force-drops the backlog once its buffer overflows,
+    // so the wallpaper "catches up" all at once instead of reacting smoothly. Capture now runs on
+    // its own thread so it keeps draining regardless of what rendering is doing.
     this->m_captureThread
 	= SDL_CreateThread (&PulseAudioPlaybackRecorder::captureThreadEntry, "lwe-audiocapture", this);
 }
@@ -281,7 +272,6 @@ void PulseAudioPlaybackRecorder::processFrame () {
 	this->m_audioFFTbuffer[i] = (this->m_captureData.audioBuffer[i] - 128) / 128.0f;
     }
 
-    // perform full fft pass
     kiss_fftr (this->m_captureData.kisscfg, this->m_audioFFTbuffer, this->m_FFTinfo);
 
     // computed into locals first so the lock only needs to be held for the final copy, not the
@@ -290,8 +280,7 @@ void PulseAudioPlaybackRecorder::processFrame () {
     float bands32[32];
     float bands16[16];
 
-    // now reduce to the different bands
-    // use just one for loop to produce all 3
+    // one loop produces all 3 band resolutions
     for (int band = 0; band < 64; band++) {
 	int index = band * 2;
 	float f1 = this->m_FFTinfo[index].r;
@@ -307,10 +296,8 @@ void PulseAudioPlaybackRecorder::processFrame () {
 	    f1 = 0.35f * log10 (f2) + kLoudnessOffset;
 	}
 
-	// written directly (no smoothing here) - the wallpaper's own script already smooths this
-	// against real elapsed time via its "smoothing" scriptproperty (see engine.frametime usage
-	// in the audio-response script snippet); an extra fixed-step smoothing pass here would just
-	// double up on that.
+	// Written directly (no smoothing here) - the wallpaper's own script already smooths this
+	// via its "smoothing" scriptproperty; an extra pass here would just double up on that.
 	bands64[band] = fmax (
 	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)))
 	);
@@ -335,8 +322,7 @@ void PulseAudioPlaybackRecorder::processFrame () {
     }
 
     // Edge-triggered marker for a loud transient (e.g. a clap) reaching the capture layer,
-    // timestamped so it can be correlated against when the transient actually happened and when
-    // the visual pulse reacts to it - isolates whether a future delay regression is in capture or
+    // timestamped to isolate whether a future audio-to-visual delay regression is in capture or
     // downstream of it.
     static bool wasLoud = false;
     float peak = 0.0f;

+ 1 - 5
src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp

@@ -23,13 +23,10 @@ void audio_callback (void* userdata, uint8_t* streamData, int length) {
 	uint8_t* streamDataPointer = streamData;
 	int streamLength = length;
 
-	// sound is not initialized or stopped and is not in loop mode
-	// ignore mixing it in
 	if (!buffer->stream->isInitialized ()) {
 	    continue;
 	}
 
-	// check if queue is empty and signal the read thread
 	if (buffer->stream->isQueueEmpty ()) {
 	    SDL_CondSignal (buffer->stream->getWaitCondition ());
 	    continue;
@@ -37,7 +34,6 @@ void audio_callback (void* userdata, uint8_t* streamData, int length) {
 
 	while (streamLength > 0 && driver->getApplicationContext ().state.general.keepRunning) {
 	    if (buffer->audio_buf_index >= buffer->audio_buf_size) {
-		// get more data to fill the buffer
 		int audio_size = buffer->stream->decodeFrame (buffer->audio_buf, sizeof (buffer->audio_buf));
 
 		if (audio_size < 0) {
@@ -71,7 +67,7 @@ void audio_callback (void* userdata, uint8_t* streamData, int length) {
 	}
     }
 
-    // TODO: DO WE NEED TO ALSO LOCK WHILE THE AUDIO IS PLAYING? OR SOMEHOW WAIT UNTIL THE STREAM IS NOT IN USE ANYMORE?
+    // TODO: do we also need to lock while audio is playing, or wait until the stream is unused?
     SDL_UnlockMutex (driver->getStreamMutex ());
 }
 

+ 4 - 23
src/WallpaperEngine/Data/Assets/Texture.h

@@ -98,21 +98,14 @@ enum TextureFlags {
 };
 
 struct Mipmap {
-    /** Width of the mipmap */
     uint32_t width = 0;
-    /** Height of the mipmap */
     uint32_t height = 0;
-    /** If the mipmap data is compressed */
+    /** Whether the mipmap data is compressed */
     uint32_t compression = 0;
-    /** Uncompressed size of the mipmap */
     int uncompressedSize = 0;
-    /** Compress size of the mipmap */
     int compressedSize = 0;
-    /** Pointer to the compressed data */
     std::unique_ptr<char[]> compressedData = nullptr;
-    /** Pointer to the uncompressed data */
     std::unique_ptr<char[]> uncompressedData = nullptr;
-    /** JSON data */
     std::string json {};
 };
 
@@ -134,35 +127,23 @@ struct Frame {
 };
 
 struct Texture {
-    /** The version of the texture container */
     ContainerVersion containerVersion = ContainerVersion_UNKNOWN;
-    /** The version of the animated data */
     AnimatedVersion animatedVersion = AnimatedVersion_UNKNOWN;
-    /** Flags with extra texture information @see TextureFlags */
+    /** Bitmask of TextureFlags, stored as raw uint32_t */
     uint32_t flags = TextureFlags_NoFlags;
-    /** Real width of the texture */
     uint32_t width = 0;
-    /** Real height of the texture */
     uint32_t height = 0;
-    /** Texture width in memory (power of 2) */
+    /** Texture size in memory (power of 2), as opposed to real width/height above */
     uint32_t textureWidth = 0;
-    /** Texture height in memory (power of 2) */
     uint32_t textureHeight = 0;
-    /** Gif width */
     uint32_t gifWidth = 0;
-    /** Gif height */
     uint32_t gifHeight = 0;
-    /** Texture data format */
     TextureFormat format = TextureFormat_UNKNOWN;
-    /** Free Image format */
+    /** FreeImage library format */
     FIF freeImageFormat = FIF_UNKNOWN;
-    /** Indicates if we have an MP4 video */
     bool isVideoMp4 = false;
-    /** The amount of images in the texture file */
     uint32_t imageCount = 0;
-    /** List of mipmaps */
     std::map<uint32_t, MipmapList> images {};
-    /** List of animation frames */
     std::vector<FrameSharedPtr> frames {};
 
     /** Spritesheet grid data (from .tex-json metadata) */

+ 0 - 6
src/WallpaperEngine/Data/Builders/ColorBuilder.cpp

@@ -15,18 +15,13 @@ WallpaperEngine::Data::Model::Color
 WallpaperEngine::Data::Builders::ColorBuilder::parse (const std::string& value, float alpha) {
     auto copy = value;
 
-    // replace the actual separators with spaces to normalize them
     if (copy.find (',') != std::string::npos) {
-	// replace comma separator with spaces so it's
 	std::ranges::replace (copy, ',', ' ');
     }
 
-    // hex colors should be converted to int colors
     if (copy.find ('#') == 0) {
 	auto number = copy.substr (1);
 
-	// expand short css notation into the right one
-	// support for css notation
 	if (number.size () == 3) {
 	    std::ostringstream expanded;
 	    expanded << number.at (0) << number.at (0) << number.at (1) << number.at (1) << number.at (2)
@@ -40,7 +35,6 @@ WallpaperEngine::Data::Builders::ColorBuilder::parse (const std::string& value,
 	    sLog.exception ("Invalid CSS color notation for ", value);
 	}
 
-	// parse hex color
 	const auto color = std::stoi (number, nullptr, 16);
 
 	return WallpaperEngine::Data::Model::Color (

+ 0 - 6
src/WallpaperEngine/Data/Builders/ColorBuilder.h

@@ -8,13 +8,7 @@
 namespace WallpaperEngine::Data::Builders {
 class ColorBuilder {
 public:
-    /**
-     * White color constant
-     */
     static const Model::Color White;
-    /**
-     * Black color constant
-     */
     static const Model::Color Black;
 
     static WallpaperEngine::Data::Model::Color parse (const std::string& value, float alpha = 1.0f);

+ 5 - 32
src/WallpaperEngine/Data/Builders/VectorBuilder.h

@@ -12,24 +12,13 @@ namespace WallpaperEngine::Data::Builders {
 using namespace WallpaperEngine::Data::Utils::SFINAE;
 
 class VectorBuilder {
-    /**
-     * Convert template that calls the proper std::strto* function
-     * based on the incoming type
-     *
-     * @tparam type
-     * @param str
-     * @return
-     */
+    /** Calls the proper std::strto* function based on the incoming type */
     template <typename type> static type convert (const char* str);
 
 public:
     /**
-     * Takes the string and returns the vector size (2, 3 or 4)
-     *
-     * TODO: THIS SHOULD BE MOVED, RENAMED OR PLACED SOMEWHERE WHERE IT MAKES MORE SENSE
-     *
-     * @param str
-     * @return
+     * Returns the vector size (1-4) encoded in a space-separated string.
+     * TODO: move/rename, doesn't really belong here
      */
     static int preparseSize (const std::string& str) {
 	const char* p = str.c_str ();
@@ -52,32 +41,17 @@ public:
 	return 4;
     }
 
-    /**
-     * Takes a string value and parses it into a glm::vec.
-     * This particular parsing uses spaces as separators and basic std::strto* functions
-     * for the actual parsing of the values.
-     *
-     * @tparam length Vector length
-     * @tparam type Vector storage type
-     * @tparam qualifier Precision qualifier
-     *
-     * @param str The string to parse the vector from
-     *
-     * @return
-     */
+    /** Parses a space-separated string into a glm::vec using std::strto* functions */
     template <int length, typename type, glm::qualifier qualifier>
     [[nodiscard]] static glm::vec<length, type, qualifier> parse (const std::string& str) {
-	// ensure a valid type is used, only 1 to 4 vectors are supported
 	static_assert (length >= 1 && length <= 4, "Invalid vector length");
 
 	const char* p = str.c_str ();
 
-	// get up to 4 spaces
 	const char* first = strchr (p, ' ');
 	const char* second = first ? strchr (first + 1, ' ') : nullptr;
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 
-	// validate lengths against what was found in the strings
 	if constexpr (length == 1) {
 	    if (first != nullptr) {
 		sLog.exception ("Invalid vector format: " + str + " (too many values, expected: ", length, ")");
@@ -103,7 +77,7 @@ public:
 	    }
 	}
 
-	// lengths validated, values can be used directly without issues
+	// lengths already validated above
 	if constexpr (length == 1) {
 	    return { convert<type> (p) };
 	} else if constexpr (length == 2) {
@@ -120,7 +94,6 @@ public:
 	constexpr int length = GlmVecTraits<T>::length;
 	constexpr glm::qualifier qualifier = GlmVecTraits<T>::qualifier;
 
-	// call the specialized version of the function
 	return parse<length, typename GlmVecTraits<T>::type, qualifier> (str);
     }
 };

+ 1 - 1
src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp

@@ -32,7 +32,7 @@ void StringPrinter::printWallpaper (const Wallpaper& wallpaper) {
 	this->increaseIndentation ();
 	this->lineEnd ();
 
-	// TODO: IMPLEMENT FBO PRINTING, AS THIS WASN'T REALLY REFLECTION HOW IT ACTUALLY WORKS
+	// TODO: implement FBO printing, current output doesn't reflect how it actually works
 	this->m_out << "Objects count: " << scene->objects.size ();
 	this->increaseIndentation ();
 

+ 2 - 87
src/WallpaperEngine/Data/Dumpers/StringPrinter.h

@@ -13,113 +13,28 @@ public:
     explicit StringPrinter (std::string indentationCharacter = "\t");
     ~StringPrinter () = default;
 
-    /**
-     * @return The contents of the pretty printer buffer
-     */
     std::string str () const;
 
-    /**
-     * Prints the information of the given wallpaper
-     *
-     * @param wallpaper
-     */
     void printWallpaper (const Wallpaper& wallpaper);
-
-    /**
-     * Prints the information of the given object
-     *
-     * @param object
-     */
     void printObject (const Object& object);
-
-    /**
-     * Prints the information of the given image
-     *
-     * @param image
-     */
     void printImage (const Image& image);
-
-    /**
-     * Prints the information of the given sound
-     *
-     * @param sound
-     */
     void printSound (const Sound& sound);
-
-    /**
-     * Prints the information of the given model
-     *
-     * @param model
-     */
     void printModel (const ModelStruct& model);
-
-    /**
-     * Prints the information of the given image effect
-     *
-     * @param imageEffect
-     */
     void printImageEffect (const ImageEffect& imageEffect);
-
-    /**
-     * Prints the information of the given image effect pass
-     *
-     * @param imageEffectPass
-     */
     void printImageEffectPassOverride (const ImageEffectPassOverride& imageEffectPass);
-
-    /**
-     * Prints the information of the given FBO
-     *
-     * @param fbo
-     */
     void printFBO (const FBO& fbo);
-
-    /**
-     * Prints the information of the given material
-     *
-     * @param material
-     */
     void printMaterial (const Material& material);
-
-    /**
-     * Prints the information of the given material pass
-     *
-     * @param materialPass
-     */
     void printMaterialPass (const MaterialPass& materialPass);
-
-    /**
-     * Prints the information of the given effect
-     *
-     * @param effect
-     */
     void printEffect (const Effect& effect);
-
-    /**
-     * Prints the information of the given effect
-     *
-     * @param effectPass
-     */
     void printEffectPass (const EffectPass& effectPass);
 
 private:
-    /**
-     * Printss the current identation level
-     */
     void indentation ();
-
-    /**
-     * Prints an end of line and indentates the next line to the aproppiate level
-     */
     void lineEnd ();
 
-    /**
-     * Increases the indentation level and prints a new line up to the specified level
-     */
+    /** Also prints a new line up to the new level, not just level bookkeeping */
     void increaseIndentation ();
-    /**
-     * Decreases the indentation level and prints a new line up to the specified level
-     */
+    /** Also prints a new line up to the new level, not just level bookkeeping */
     void decreaseIndentation ();
 
     int m_level = 0;

+ 5 - 13
src/WallpaperEngine/Data/JSON.h

@@ -41,17 +41,15 @@ public:
 	constexpr int length = GlmVecTraits<T>::length;
 	constexpr glm::qualifier qualifier = GlmVecTraits<T>::qualifier;
 
-	// call the specialized version of the function
 	return get<length, typename GlmVecTraits<T>::type, qualifier> ();
     }
     template <int length, typename type, glm::qualifier qualifier>
     [[nodiscard]] glm::vec<length, type, qualifier> get () const {
 	const auto& node = this->base ();
 
-	// Most real scenes store vectors as "x y z" strings (VectorBuilder's only format), but some
-	// fields in the wild (text objects' "size"/"padding") show up as a bare number (uniform
-	// across every component) or a JSON array instead - callers like optional(key, default)
-	// below rely on this being noexcept, so fall back to zero and log rather than throw.
+	// Most scenes store vectors as "x y z" strings, but some fields (text objects' "size"/"padding")
+	// show up as a bare number (uniform across components) or a JSON array instead. Callers rely on
+	// this being noexcept, so fall back to zero and log rather than throw.
 	try {
 	    if (node.is_array ()) {
 		glm::vec<length, type, qualifier> result (static_cast<type> (0));
@@ -144,8 +142,7 @@ public:
 	    return UserSettingBuilder::fromValue<T> (defaultValue);
 	}
 
-	// performs a second lookup, but handles the actual call to UserSettingParser outside of this header
-	// this resolving the include loop
+	// second lookup, but the actual UserSettingParser call lives outside this header to avoid an include loop
 	return this->user (key, properties);
     }
     [[nodiscard]] UserSettingUniquePtr color (const std::string& key, const Properties& properties) const;
@@ -157,8 +154,7 @@ public:
 	    return UserSettingBuilder::fromValue<Color> (defaultValue);
 	}
 
-	// performs a second lookup, but handles the actual call to UserSettingParser outside of this header
-	// this resolving the include loop
+	// second lookup, but the actual UserSettingParser call lives outside this header to avoid an include loop
 	return this->color (key, properties);
     }
 
@@ -169,14 +165,10 @@ public:
 	constexpr int length = GlmVecTraits<T>::length;
 	constexpr glm::qualifier qualifier = GlmVecTraits<T>::qualifier;
 
-	// call the specialized version of the function
 	return operator glm::vec<length, typename GlmVecTraits<T>::type, qualifier> ();
     }
 
 private:
-    /**
-     * @return The base json object to be used by the extension methods
-     */
     [[nodiscard]] const base_type& base () const { return *static_cast<const base_type*> (this); }
 };
 

+ 3 - 4
src/WallpaperEngine/Data/Model/DynamicValue.cpp

@@ -38,7 +38,7 @@ DynamicValue::DynamicValue (const Model::Color& value) {
 }
 
 DynamicValue::~DynamicValue () {
-    // TODO: PROPERLY FIX THESE
+    // TODO: properly fix this lifetime handling
     if (this->m_aliveFlag) {
 	*this->m_aliveFlag = false;
     }
@@ -250,8 +250,7 @@ std::function<void ()> DynamicValue::listen (const std::function<void (const Dyn
 
 void DynamicValue::connect (DynamicValue* other) {
     const auto lambda = [this] (const DynamicValue& other, UpdateSource source) {
-	// null is a special case, copying everything to 0 is different,
-	// so calling the update without parameters is required
+	// null needs the no-arg update() - copying everything to 0 is a different meaning
 	if (other.getType () == UnderlyingType::Null) {
 	    this->update (source);
 	} else {
@@ -261,7 +260,7 @@ void DynamicValue::connect (DynamicValue* other) {
 
     const auto deregisterFunction = other->listen (lambda);
 
-    // same update cycle has to happen as in the lambda, so trigger it
+    // trigger the same update cycle immediately for the initial value
     lambda (*other, UpdateSource::Initialization);
 
     this->m_connections.push_back (deregisterFunction);

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

@@ -26,9 +26,6 @@ struct ScriptContext {
     } object;
 };
 
-/**
- * Class that represents different types of dynamic values
- */
 class DynamicValue {
 public:
     enum UnderlyingType {
@@ -79,68 +76,24 @@ public:
     virtual void update (const std::string& newValue, UpdateSource source);
     virtual void update (const Model::Color& newValue, UpdateSource source);
     virtual void update (const DynamicValue& other, UpdateSource source);
-    /**
-     * Sets the current value to null
-     */
+    /** Sets the current value to null */
     virtual void update (UpdateSource source);
 
-    /**
-     * Registers the given callback to be called when the value changes
-     *
-     * @param callback
-     *
-     * @return The de-register function to call when the listener is no longer needed
-     */
+    /** Returns a de-register function to call when the listener is no longer needed */
     std::function<void ()> listen (const std::function<void (const DynamicValue&, UpdateSource)>& callback);
-    /**
-     * Connects the current instance to the given instance, updating this instance's value
-     * based on the given instance's value
-     *
-     * @param other
-     *
-     * @return The de-register function to call when the listener is no longer needed
-     */
+    /** Connects to another instance; this instance's value tracks the other's */
     void connect (DynamicValue* other);
 
-    /**
-     * Disconnects the current instance from all the connections to stop notifying
-     * new value changes
-     */
     void disconnect ();
 
-    /**
-     * Associates a condition with the dynamic value to apply proper checks
-     *
-     * @param condition
-     */
     void attachCondition (const ConditionInfo& condition);
-    /**
-     * Associates a script source with the dynamic value to allow for dynamic updates
-     *
-     * @param source The script code to associate to this value
-     */
     void setScriptSource (const std::string& source);
-    /**
-     * Clears the associated script to this dynamic value
-     */
     void clearScriptSource ();
-    /**
-     * @return The current script source associated with this dynamic value
-     */
     [[nodiscard]] const std::optional<std::string>& getScriptSource () const;
-    /**
-     * @return The script properties associated with this dynamic value (if any)
-     */
     [[nodiscard]] std::map<std::string, UserSettingUniquePtr>& getProperties ();
-    /**
-     * Updates the script properties associated with this dynamic value
-     */
     void setProperties (std::map<std::string, UserSettingUniquePtr> properties);
 
 private:
-    /**
-     * Notifies any listeners that the value has changed
-     */
     void propagate (UpdateSource source) const;
 
     std::shared_ptr<bool> m_aliveFlag = std::make_shared<bool> (true);
@@ -157,7 +110,7 @@ private:
     std::string m_string;
     UnderlyingType m_type = Null;
     std::optional<ConditionInfo> m_condition = std::nullopt;
-    /** All the properties this script takes in */
+    /** Properties the associated script takes in */
     std::map<std::string, UserSettingUniquePtr> m_properties;
 };
 }

+ 3 - 12
src/WallpaperEngine/Data/Model/Effect.h

@@ -16,32 +16,23 @@ struct FBO {
 };
 
 struct EffectPass {
-    /** The material to use for this effect's pass */
     std::optional<MaterialUniquePtr> material;
-    /** Texture bindings for this effect's pass */
     TextureMap binds;
-    /** The command this material executes (if specified) */
     std::optional<PassCommandType> command;
-    /** The source this material renders from (if specified) */
     std::optional<std::string> source;
-    /** The target this material renders to (if specified) */
     std::optional<std::string> target;
 };
 
 struct Effect {
-    /** Effect's name for the UI */
+    /** For the UI */
     std::string name;
-    /** Effect's description for the UI */
+    /** For the UI */
     std::string description;
-    /** Effect's group for the UI */
+    /** For the UI */
     std::string group;
-    /** Effect's preview project */
     std::string preview;
-    /** Effect's dependencies */
     std::vector<std::string> dependencies;
-    /** The different passes for this effect */
     std::vector<EffectPassUniquePtr> passes;
-    /** The fbos declared by this effect */
     std::vector<FBOUniquePtr> fbos;
 };
 } // namespace WallpaperEngine::Data::Model

+ 1 - 11
src/WallpaperEngine/Data/Model/Material.h

@@ -31,30 +31,20 @@ enum DepthwriteMode {
 };
 
 struct MaterialPass {
-    /** Blending mode */
     BlendingMode blending;
-    /** Culling mode */
     CullingMode cullmode;
-    /** Depth test mode */
     DepthtestMode depthtest;
-    /** Depth write mode */
     DepthwriteMode depthwrite;
-    /** Shader file to use for this pass */
     std::string shader;
-    /** List of textures defined for this pass */
     TextureMap textures;
-    /** List of user textures defined for this pass */
     TextureMap usertextures;
-    /** The combos and their values to pass onto the shader */
     ComboMap combos;
-    /** Constant shader values (e.g., overbright, bloom settings) */
+    /** e.g. overbright, bloom settings */
     ShaderConstantMap constants;
 };
 
 struct Material {
-    /** The name of the file this material is defined in */
     std::string filename;
-    /** The passes that compose this material */
     std::vector<MaterialPassUniquePtr> passes;
 };
 

+ 8 - 8
src/WallpaperEngine/Data/Model/Model.h

@@ -3,30 +3,30 @@
 #include <optional>
 #include <string>
 
+#include <glm/vec2.hpp>
+
 #include "Types.h"
 
 namespace WallpaperEngine::Data::Model {
 // TODO: FIND A BETTER NAMING SO THIS DOESN'T COLLIDE WITH THE NAMESPACE ITSELF
 struct ModelStruct {
-    /** The filename of the model */
     std::string filename;
-    /** The material used for this model */
     MaterialUniquePtr material;
-    /** Whether this model is a solid layer */
     bool solidlayer;
-    /** Whether this model is a fullscreen layer */
+    /** Marked GPU-instanced but not actually batched - each object still renders individually with
+     *  its own transform/color, visually equivalent to batching but not a single draw call. */
+    bool instanced;
     bool fullscreen;
-    /** Whether this model is a passthrough layer */
     bool passthrough;
-    /** Whether this models's size should be determined automatically or not */
     bool autosize;
-    /** Whether this models's padding should be disabled or not */
     bool nopadding;
     /** Not sure what's used for */
     std::optional<int> width;
     /** Not sure what's used for */
     std::optional<int> height;
-    /** Model file for puppet */
+    /** Offset of the autosize canvas center from the puppet's actual content pivot - needed when the
+     *  rig doesn't sit in the middle of its bounding box (e.g. an arm attached at the wrist). */
+    std::optional<glm::vec2> cropOffset;
     std::optional<std::string> puppet;
 };
 } // namespace WallpaperEngine::Data::Model

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

@@ -25,7 +25,8 @@ struct ObjectData {
     std::string name;
     std::vector<int> dependencies;
     std::optional<int> parent;
-    /** The point of origin of the object */
+    /** Name of a named attachment point on the parent's puppet rig to follow, if any */
+    std::optional<std::string> attachment;
     UserSettingUniquePtr origin;
     /** Transform fields for generic scene/group objects. Typed objects keep their own transform fields. */
     UserSettingUniquePtr groupScale;
@@ -33,27 +34,12 @@ struct ObjectData {
     UserSettingUniquePtr groupVisible;
 };
 
-/**
- * Base class for all objects, represents a single object in the scene
- *
- * @see Image
- * @see Sound
- * @see Particle
- * @see Text
- * @see Light
- */
 class Object : public TypeCaster, public ObjectData {
 public:
     explicit Object (ObjectData data) noexcept : TypeCaster (), ObjectData (std::move (data)) { };
     ~Object () override = default;
 };
 
-/**
- * Overrides effect's passes configuration
- *
- * @see ImageEffect
- * @see EffectPass
- */
 struct ImageEffectPassOverride {
     int id;
     ComboMap combos;
@@ -63,32 +49,20 @@ struct ImageEffectPassOverride {
     std::optional<std::string> shaderOverride; // Overrides MaterialPass::shader when set
 };
 
-/**
- * Override information for an specific effect
- *
- * @see ImageEffect
- * @see Effect
- * @see EffectPass
- * @see ImageEffectPass
- */
 struct ImageEffect {
     /** Not sure what it's used for */
     int id;
     /** Effect's name for the editor */
     std::string name;
-    /** If this effect is visible or not */
     UserSettingUniquePtr visible;
-    /** Pass overrides to apply to the effect's passes */
     std::vector<ImageEffectPassOverrideUniquePtr> passOverrides;
-    /** The effect definition */
     EffectUniquePtr effect;
 };
 
-/**
- * Animation layers for the puppet warp
- */
 struct ImageAnimationLayer {
     int id;
+    /** Matches the name of a baked animation clip stored in the puppet .mdl's MDLA section */
+    std::string name;
     UserSettingUniquePtr rate;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr blend;
@@ -96,32 +70,23 @@ struct ImageAnimationLayer {
 };
 
 struct ImageData {
-    /** The scale of the image */
     UserSettingUniquePtr scale;
-    /** The rotation of the image */
     UserSettingUniquePtr angles;
-    /** If the image is visible or not */
     UserSettingUniquePtr visible;
-    /** The alpha of the image */
     UserSettingUniquePtr alpha;
-    /** The color of the image */
     UserSettingUniquePtr color;
-    // TODO: WRITE A COUPLE OF ENUMS FOR THIS
-    /** The alignment of the image */
+    // TODO: write a couple of enums for this
     std::string alignment;
-    /** The size of the image in pixels */
+    /** In pixels */
     glm::vec2 size;
-    /** Parallax depth used for parallax scrolling */
     UserSettingUniquePtr parallaxDepth;
-    /** The color blending mode for this image */
     UserSettingUniquePtr colorBlendMode;
-    /** The brightness of the image */
     UserSettingUniquePtr brightness;
-    /** The material in use for this image */
+    /** Forces UV clamping on this object's composite buffers regardless of the base texture's own flags */
+    bool clampUVs;
     ModelUniquePtr model;
-    /** The effects applied to this image after the material is rendered */
+    /** Applied after the material is rendered */
     std::vector<ImageEffectUniquePtr> effects;
-    /** The animation layers used in the puppet warp */
     std::vector<ImageAnimationLayerUniquePtr> animationLayers;
 };
 
@@ -133,10 +98,12 @@ public:
 };
 
 struct SoundData {
-    /** Playback mode, loop, */
-    // TODO: WRITE AN ENUM FOR THIS
+    // TODO: write an enum for this
     std::optional<std::string> playbackmode;
     std::vector<std::string> sounds;
+    /** 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;
 };
 
 class Sound : public Object, public SoundData {
@@ -544,28 +511,22 @@ struct ParticleInstanceOverride {
 };
 
 struct ParticleData {
-    /** Position and transformation */
     UserSettingUniquePtr scale;
     UserSettingUniquePtr angles;
     UserSettingUniquePtr visible;
 
-    /** Parallax depth */
     UserSettingUniquePtr parallaxDepth;
 
-    /** Reference to particle definition file */
     std::string particleFile;
 
-    /** Particle system configuration */
     std::string animationMode;
     float sequenceMultiplier;
     uint32_t maxCount;
     uint32_t startTime;
     uint32_t flags;
 
-    /** Material for rendering */
     ModelUniquePtr material;
 
-    /** Emitters, initializers, operators, renderers */
     std::vector<ParticleEmitter> emitters;
     std::vector<ParticleInitializerUniquePtr> initializers;
     std::vector<ParticleOperatorUniquePtr> operators;
@@ -573,7 +534,6 @@ struct ParticleData {
     std::vector<ParticleControlPoint> controlPoints;
     std::vector<ParticleChild> children;
 
-    /** Instance override */
     ParticleInstanceOverride instanceOverride;
 };
 
@@ -602,11 +562,8 @@ struct TextData {
     UserSettingUniquePtr scale;
     /** Text color as linear-space RGB */
     UserSettingUniquePtr color;
-    /** Alpha multiplier */
     UserSettingUniquePtr alpha;
-    /** Whether the text is visible */
     UserSettingUniquePtr visible;
-    /** Parallax depth used for parallax scrolling */
     UserSettingUniquePtr parallaxDepth;
     /** Horizontal alignment: "left", "center", "right" */
     std::string alignment;
@@ -614,9 +571,9 @@ struct TextData {
     std::string verticalalign;
     /** Padding inside the bounding box (x = horizontal, y = vertical) */
     glm::vec2 padding;
-    /** The effects applied to this text after the glyphs are rendered */
+    /** Applied after the glyphs are rendered */
     std::vector<ImageEffectUniquePtr> effects;
-    // TODO: PARSE LIMITS TOO!
+    // TODO: parse limits too
 };
 
 class Text : public Object, public TextData {

+ 2 - 10
src/WallpaperEngine/Data/Model/Project.h

@@ -8,25 +8,17 @@
 
 namespace WallpaperEngine::Data::Model {
 using namespace WallpaperEngine::Assets;
-/**
- * Represents a wallpaper engine project
- */
 struct Project {
     enum Type { Type_Scene = 0, Type_Web = 1, Type_Video = 2, Type_Unknown = 3 };
 
-    /** Wallpapers title */
     std::string title;
-    /** Wallpaper's type */
     Type type;
-    /** Workshop ID of the background or a negative id if not present */
+    /** Negative if not present */
     std::string workshopId;
-    /** Indicates if the background uses audio processing or not */
     bool supportsAudioProcessing;
-    /** All the available properties that the project defines for the user to change */
+    /** User-configurable properties exposed by the project */
     Properties properties;
-    /** The wallpaper this project defines */
     WallpaperUniquePtr wallpaper;
-    /** Abstraction over asset loading to provide access to them */
     AssetLocatorUniquePtr assetLocator;
 };
 };

+ 0 - 1
src/WallpaperEngine/Data/Model/Property.h

@@ -119,7 +119,6 @@ public:
 	    return;
 	}
 
-	// search for the value in the combo options or default to the textual value
 	this->DynamicValue::update (value, source);
     }
 

+ 1 - 23
src/WallpaperEngine/Data/Model/Wallpaper.h

@@ -43,29 +43,16 @@ struct SceneData {
 	UserSettingUniquePtr skylight;
 	UserSettingUniquePtr clear;
     } colors;
-    /**
-     * Camera configuration
-     */
     struct Camera {
-	/** Enable fade effect */
 	UserSettingUniquePtr fade;
-	/** Used by the software to allow the users to preview the background or not? */
+	/** Whether the software's preview UI is allowed to show this background */
 	bool preview;
 
-	/**
-	 * Bloom effect configuration
-	 */
 	struct {
-	    /** If bloom is enabled or not */
 	    UserSettingUniquePtr enabled;
-	    /** Bloom's strength to pass onto the shader */
 	    UserSettingUniquePtr strength;
-	    /** Bloom's threshold to pass onto the shader */
 	    UserSettingUniquePtr threshold;
 	} bloom;
-	/**
-	 * Parallax effect configuration
-	 */
 	struct {
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr amount;
@@ -73,9 +60,6 @@ struct SceneData {
 	    UserSettingUniquePtr mouseInfluence;
 	} parallax;
 
-	/**
-	 * Shake effect configuration
-	 */
 	struct {
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr amplitude;
@@ -83,18 +67,12 @@ struct SceneData {
 	    UserSettingUniquePtr speed;
 	} shake;
 
-	/**
-	 * Position configuration
-	 */
 	struct {
 	    glm::vec3 center;
 	    glm::vec3 eye;
 	    glm::vec3 up;
 	} configuration;
 
-	/**
-	 * Projection information
-	 */
 	struct {
 	    int width;
 	    int height;

+ 0 - 3
src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp

@@ -22,7 +22,6 @@ DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Propert
 	}
     }
 
-    // actual value parsing
     if (valueIt.is_string ()) {
 	if (expectColor) {
 	    value->update (Builders::ColorBuilder::parse (valueIt), DynamicValue::UpdateSource::Initialization);
@@ -31,7 +30,6 @@ DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Propert
 	    int size = Builders::VectorBuilder::preparseSize (str);
 
 	    if (size == 1) {
-		// scalar? text value?
 		std::size_t parsed = 0;
 		try {
 		    float f = std::stof (str, &parsed);
@@ -59,7 +57,6 @@ DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Propert
     } else if (valueIt.is_boolean ()) {
 	value->update (valueIt.get<bool> (), DynamicValue::UpdateSource::Initialization);
     } else if (valueIt.is_null ()) {
-	// null value with no connection to property
 	value->update (DynamicValue::UpdateSource::Initialization);
     }
 

+ 1 - 1
src/WallpaperEngine/Data/Parsers/EffectParser.cpp

@@ -57,7 +57,7 @@ std::vector<EffectPassUniquePtr> EffectParser::parseEffectPasses (const JSON& it
 	const auto command = cur.optional ("command");
 	const auto material = cur.optional ("material");
 
-	// TODO: CAN TARGET BE SET IF MATERIAL IS SET?
+	// TODO: can target be set if material is set?
 
 	result.push_back (
 	    std::make_unique<EffectPass> (EffectPass {

+ 1 - 1
src/WallpaperEngine/Data/Parsers/MaterialParser.cpp

@@ -43,7 +43,7 @@ MaterialPassUniquePtr MaterialParser::parsePass (const JSON& it, const Project&
     const auto constants = it.optional ("constantshadervalues");
 
     return std::make_unique<MaterialPass> (MaterialPass {
-	// TODO: REMOVE THIS UGLY STD::STRING CREATION
+	// TODO: avoid this std::string construction
 	.blending = parseBlendMode (it.optional ("blending", std::string ("normal"))),
 	.cullmode = parseCullMode (it.optional ("cullmode", std::string ("nocull"))),
 	.depthtest = parseDepthtestMode (it.optional ("depthtest", std::string ("disabled"))),

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

@@ -23,12 +23,14 @@ ModelUniquePtr ModelParser::parse (const JSON& file, const Project& project, con
 	.filename = filename,
 	.material = MaterialParser::load (project, material),
 	.solidlayer = file.optional ("solidlayer", false),
+	.instanced = file.optional ("instanced", false),
 	.fullscreen = file.optional ("fullscreen", false),
 	.passthrough = file.optional ("passthrough", false),
 	.autosize = file.optional ("autosize", false),
 	.nopadding = file.optional ("nopadding", false),
 	.width = file.optional<int> ("width"),
 	.height = file.optional<int> ("height"),
+	.cropOffset = file.optional<glm::vec2> ("cropoffset"),
 	.puppet = file.optional<std::string> ("puppet"),
     });
 }

+ 21 - 31
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -23,11 +23,10 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
-    // use shape to refer to VolumeLight
+    // "shape" refers to VolumeLight
     const auto shapeIt = it.find ("shape");
 
-    // Parse base object data
-    // Some particle objects have numeric 'name' fields, so handle type mismatches gracefully
+    // some particle objects have numeric 'name' fields, so handle type mismatches gracefully
     ObjectData basedata;
     try {
 	basedata = ObjectData {
@@ -35,6 +34,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .name = it.require<std::string> ("name", "Object must have a name"),
 	    .dependencies = parseDependencies (it),
 	    .parent = it.optional<int> ("parent"),
+	    .attachment = it.optional<std::string> ("attachment"),
 	    .origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
 	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
@@ -58,6 +58,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .name = name,
 	    .dependencies = parseDependencies (it),
 	    .parent = it.optional<int> ("parent"),
+	    .attachment = it.optional<std::string> ("attachment"),
 	    .origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
 	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
@@ -68,7 +69,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     if (imageIt != it.end () && imageIt->is_string ()) {
 	return parseImage (it, project, std::move (basedata), *imageIt);
     } else if (soundIt != it.end () && soundIt->is_array ()) {
-	return parseSound (it, std::move (basedata));
+	return parseSound (it, project, std::move (basedata));
     } else if (particleIt != it.end () && !particleIt->is_null ()) {
 	return parseParticle (it, project, std::move (basedata));
     } else if (textIt != it.end () && !textIt->is_null ()) {
@@ -79,9 +80,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	sLog.error ("VolumeLight objects are not supported yet");
     } else {
 	if (!it.optional ("solid", false)) {
-	    // dump the object for now, might want to change later
-	    // TODO: RE-EVALUATE IF THIS MAKES SENSE, THERE'S OBJECTS THAT CONTAIN OTHER OBJECTS AND THUS AREN'T REALLY
-	    // ANYTHING SPECIAL
+	    // TODO: re-evaluate - some objects contain other objects and aren't really anything special
 	    sLog.error ("Unknown object type found: ", it.dump ());
 	}
     }
@@ -105,7 +104,7 @@ std::vector<int> ObjectParser::parseDependencies (const JSON& it) {
     return result;
 }
 
-SoundUniquePtr ObjectParser::parseSound (const JSON& it, ObjectData base) {
+SoundUniquePtr ObjectParser::parseSound (const JSON& it, const Project& project, ObjectData base) {
     const auto soundIt = it.require ("sound", "Object must have a sound");
     std::vector<std::string> sounds = {};
 
@@ -118,6 +117,7 @@ SoundUniquePtr ObjectParser::parseSound (const JSON& it, ObjectData base) {
 	SoundData {
 	    .playbackmode = it.optional<std::string> ("playbackmode"),
 	    .sounds = sounds,
+	    .volume = it.user<float> ("volume", project.properties, 1.0f),
 	}
     );
 }
@@ -164,6 +164,7 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
 	    .brightness = it.user ("brightness", properties, 1.0f),
+	    .clampUVs = it.optional ("clampuvs", false),
 	    .model = ModelParser::load (project, image),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .animationLayers = animationLayers.has_value () ? parseAnimationLayers (*animationLayers, project)
@@ -239,7 +240,7 @@ ImageEffectPassOverrideUniquePtr ObjectParser::parseEffectPass (const JSON& it,
     const auto& constants = it.optional ("constantshadervalues");
     const auto& usertextures = it.optional ("usertextures");
 
-    // TODO: PARSE CONSTANT SHADER VALUES AND FIND REFS?
+    // TODO: parse constant shader values and find refs?
     return std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
 	.id = it.optional<int> ("id", -1),
 	.combos = combos.has_value () ? parseComboMap (combos.value ()) : ComboMap {},
@@ -284,6 +285,7 @@ ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it,
 
     return std::make_unique<ImageAnimationLayer> (ImageAnimationLayer {
 	.id = it.require<int> ("id", "Animation layer must have an id"),
+	.name = it.optional<std::string> ("name", ""),
 	.rate = it.user ("rate", properties, 1.0f),
 	.visible = it.user ("visible", properties, false),
 	.blend = it.user ("blend", properties, 1.0f),
@@ -338,7 +340,6 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    particleFile = particleIt->get<std::string> ();
 	}
 
-	// Load particle definition from file if it's a string reference
 	JSON particleJson = JSON::object ();
 	if (!particleFile.empty ()) {
 	    try {
@@ -351,7 +352,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    particleJson = *particleIt;
 	}
 
-	// Parse emitters (note: field is named "emitter" not "emitters")
+	// field is named "emitter" not "emitters"
 	std::vector<ParticleEmitter> emitters;
 	const auto emittersIt = particleJson.find ("emitter");
 	if (emittersIt != particleJson.end () && emittersIt->is_array ()) {
@@ -360,7 +361,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse initializers (note: field is named "initializer" not "initializers")
+	// field is named "initializer" not "initializers"
 	std::vector<ParticleInitializerUniquePtr> initializers;
 	const auto initializersIt = particleJson.find ("initializer");
 	if (initializersIt != particleJson.end () && initializersIt->is_array ()) {
@@ -372,7 +373,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse operators (note: field is named "operator" not "operators")
+	// field is named "operator" not "operators"
 	std::vector<ParticleOperatorUniquePtr> operators;
 	const auto operatorsIt = particleJson.find ("operator");
 	if (operatorsIt != particleJson.end () && operatorsIt->is_array ()) {
@@ -384,7 +385,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse renderers (note: field is named "renderer" not "renderers")
+	// field is named "renderer" not "renderers"
 	std::vector<ParticleRenderer> renderers;
 	const auto renderersIt = particleJson.find ("renderer");
 	if (renderersIt != particleJson.end () && renderersIt->is_array ()) {
@@ -393,7 +394,6 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Add default sprite renderer if none specified
 	if (renderers.empty ()) {
 	    renderers.push_back (
 		ParticleRenderer {
@@ -412,7 +412,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    );
 	}
 
-	// Parse control points (note: field is named "controlpoint" not "controlpoints")
+	// field is named "controlpoint" not "controlpoints"
 	std::vector<ParticleControlPoint> controlPoints;
 	const auto controlPointsIt = particleJson.find ("controlpoint");
 	if (controlPointsIt != particleJson.end () && controlPointsIt->is_array ()) {
@@ -421,7 +421,6 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse children
 	std::vector<ParticleChild> children;
 	const auto childrenIt = particleJson.optional ("children");
 	if (childrenIt.has_value () && childrenIt->is_array ()) {
@@ -430,7 +429,6 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse instance override
 	ParticleInstanceOverride instanceOverride = {
 	    .enabled = Builders::UserSettingBuilder::fromValue (false),
 	    .alpha = Builders::UserSettingBuilder::fromValue (1.0f),
@@ -447,15 +445,13 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    instanceOverride = parseParticleInstanceOverride (*instanceOverrideIt, project.properties);
 	}
 
-	// Parse material - particles reference materials directly, not models
+	// particles reference material definitions directly, not model files, so wrap it in a model structure
 	ModelUniquePtr material = nullptr;
 	const auto materialIt = particleJson.find ("material");
 	if (materialIt != particleJson.end () && materialIt->is_string ()) {
 	    try {
 		std::string materialPath = materialIt->get<std::string> ();
 
-		// Particle materials are stored as just material definitions, not model files
-		// So we need to wrap them in a model structure
 		auto mat = MaterialParser::load (project, materialPath);
 
 		material = std::make_unique<ModelStruct> (ModelStruct {
@@ -475,14 +471,12 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	}
 
-	// Parse string fields safely
 	std::string animationMode = "sequence";
 	const auto animModeIt = particleJson.find ("animationmode");
 	if (animModeIt != particleJson.end () && animModeIt->is_string ()) {
 	    animationMode = animModeIt->get<std::string> ();
 	}
 
-	// Parse numeric fields safely
 	float sequenceMultiplier = 1.0f;
 	uint32_t maxCount = 100;
 	uint32_t startTime = 0;
@@ -539,14 +533,13 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 }
 
 ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
-    // Parse string name safely
     std::string name;
     const auto nameIt = it.find ("name");
     if (nameIt != it.end () && nameIt->is_string ()) {
 	name = nameIt->get<std::string> ();
     }
 
-    // Helper lambda to parse vec3 fields that might be strings, arrays, single numbers, or missing
+    // vec3 fields may show up as strings, arrays, single numbers, or be missing entirely
     auto parseVec3 = [&] (const char* fieldName, const glm::vec3& defaultValue) -> glm::vec3 {
 	const auto fieldIt = it.find (fieldName);
 	if (fieldIt == it.end ()) {
@@ -556,7 +549,7 @@ ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
 	    return it.optional (fieldName, defaultValue);
 	}
 	if (fieldIt->is_number ()) {
-	    // Single number - use for all components (common for distancemax/distancemin)
+	    // single number applies to all components (common for distancemax/distancemin)
 	    float val = fieldIt->get<float> ();
 	    return glm::vec3 (val, val, val);
 	}
@@ -769,7 +762,6 @@ ParticleRenderer ObjectParser::parseParticleRenderer (const JSON& it) {
 	name = nameIt->get<std::string> ();
     }
 
-    // Renderer-type-specific defaults
     float subdivisionDefault = (name == "rope") ? 4.0f : 1.0f;
     float lengthDefault = (name == "ropetrail") ? 1.0f : 0.05f;
 
@@ -789,17 +781,15 @@ ParticleRenderer ObjectParser::parseParticleRenderer (const JSON& it) {
 }
 
 ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
-    // Parse offset - can be string "x y z" or array [x,y,z]
+    // offset can be string "x y z" or array [x,y,z]
     glm::vec3 offset (0.0f);
     const auto offsetIt = it.find ("offset");
     if (offsetIt != it.end ()) {
 	if (offsetIt->is_string ()) {
-	    // Parse string format "x y z"
 	    std::string offsetStr = offsetIt->get<std::string> ();
 	    std::istringstream iss (offsetStr);
 	    iss >> offset.x >> offset.y >> offset.z;
 	} else {
-	    // Try parsing as vec3 directly
 	    try {
 		offset = it.optional ("offset", glm::vec3 (0.0f));
 	    } catch (...) {
@@ -835,7 +825,7 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 	name = nameIt->get<std::string> ();
     }
 
-    // Helper lambda to parse vec3 fields that might be strings, arrays, single numbers, or missing
+    // vec3 fields may show up as strings, arrays, single numbers, or be missing entirely
     auto parseVec3 = [&] (const char* fieldName, const glm::vec3& defaultValue) -> glm::vec3 {
 	const auto fieldIt = it.find (fieldName);
 	if (fieldIt == it.end ()) {

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

@@ -19,7 +19,7 @@ public:
 
 private:
     static std::vector<int> parseDependencies (const JSON& it);
-    static SoundUniquePtr parseSound (const JSON& it, ObjectData base);
+    static SoundUniquePtr parseSound (const JSON& it, const Project& project, ObjectData base);
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base);
@@ -33,7 +33,6 @@ private:
     static std::vector<ImageAnimationLayerUniquePtr> parseAnimationLayers (const JSON& it, const Project& project);
     static ImageAnimationLayerUniquePtr parseAnimationLayer (const JSON& it, const Project& project);
 
-    // Particle parsing helpers
     static ParticleEmitter parseParticleEmitter (const JSON& it);
     static ParticleInitializerUniquePtr parseParticleInitializer (const JSON& it, const Properties& properties);
     static ParticleOperatorUniquePtr parseParticleOperator (const JSON& it, const Properties& properties);

+ 1 - 2
src/WallpaperEngine/Data/Parsers/ProjectParser.cpp

@@ -29,7 +29,6 @@ ProjectUniquePtr ProjectParser::parse (const JSON& data, AssetLocatorUniquePtr c
 	}
     }
 
-    // lowercase for consistency
     std::ranges::transform (type, type.begin (), tolower);
 
     auto result = std::make_unique<Project> (Project {
@@ -78,7 +77,7 @@ Properties ProjectParser::parseProperties (const std::optional<JSON>& data) {
     for (const auto& cur : properties.value ().items ()) {
 	const auto& property = PropertyParser::parse (cur.value (), cur.key ());
 
-	// ignore properties that failed, these are generally groups
+	// null means the entry was a group, not an actual property
 	if (property == nullptr) {
 	    continue;
 	}

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

@@ -5,7 +5,7 @@ using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 
 PropertySharedPtr PropertyParser::parse (const JSON& it, const std::string& name) {
-    // type might not be included, in which case means the same as a group
+    // missing type means the same as type "group"
     const auto type = it.optional ("type");
 
     if (type == "color") {
@@ -37,7 +37,6 @@ PropertySharedPtr PropertyParser::parse (const JSON& it, const std::string& name
     }
 
     if (type.has_value () && type != "group") {
-	// show the error and ignore this property
 	sLog.error ("Unexpected type for property: ", type);
 	sLog.error (it.dump ());
     }

+ 9 - 21
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -40,16 +40,13 @@ TextureUniquePtr TextureParser::parse (const BinaryReader& file) {
 MipmapSharedPtr TextureParser::parseMipmap (const BinaryReader& file, const Texture& header) {
     auto result = std::make_shared<Mipmap> ();
 
-    // TEXB0004 has some extra data in the header that has to be handled
+    // TEXB0004 has extra header data
     if (header.containerVersion == ContainerVersion_TEXB0004) {
-	// some integers that we can ignore as they only seem to affect
-	// the editor
+	// two integers that only seem to affect the editor
 	std::ignore = file.nextUInt32 ();
 	std::ignore = file.nextUInt32 ();
-	// this format includes some json in the header that we might need
-	// to parse at some point...
+	// json blob, not parsed yet
 	result->json = file.nextNullTerminatedString ();
-	// last ignorable integer
 	std::ignore = file.nextUInt32 ();
     }
 
@@ -65,8 +62,7 @@ MipmapSharedPtr TextureParser::parseMipmap (const BinaryReader& file, const Text
     result->compressedSize = file.nextInt ();
 
     if (result->compression == 0) {
-	// this might be better named as mipmap_bytes_size instead of compressedSize
-	// as in uncompressed files this variable actually holds the file length
+	// misnamed: in uncompressed files compressedSize actually holds the file length
 	result->uncompressedSize = result->compressedSize;
     }
 
@@ -74,9 +70,7 @@ MipmapSharedPtr TextureParser::parseMipmap (const BinaryReader& file, const Text
 
     if (result->compression == 1) {
 	result->compressedData = std::unique_ptr<char[]> (new char[result->compressedSize]);
-	// read the compressed data into the buffer
 	file.next (result->compressedData.get (), result->compressedSize);
-	// finally decompress it
 	int bytes = LZ4_decompress_safe (
 	    result->compressedData.get (), result->uncompressedData.get (), result->compressedSize,
 	    result->uncompressedSize
@@ -219,7 +213,7 @@ void TextureParser::parseContainer (Texture& header, const BinaryReader& file) {
 	    header.freeImageFormat = FIF_MP4;
 	}
 
-	// default to TEXB0003 format here
+	// TEXB0004 behaves like TEXB0003 unless it's actually an MP4
 	if (header.freeImageFormat != FIF_MP4) {
 	    header.containerVersion = ContainerVersion_TEXB0003;
 	}
@@ -238,7 +232,6 @@ void TextureParser::parseContainer (Texture& header, const BinaryReader& file) {
 void TextureParser::parseAnimations (Texture& header, const BinaryReader& file) {
     char magic[9] = { 0 };
 
-    // image is animated, keep parsing the rest of the image info
     file.next (magic, 9);
 
     if (strncmp (magic, "TEXS0001", 9) == 0) {
@@ -266,14 +259,13 @@ void TextureParser::parseAnimations (Texture& header, const BinaryReader& file)
 	}
     }
 
-    // ensure gif width and height is right for TEXS0001, TEXS0002
+    // TEXS0001/TEXS0002 don't carry gif dimensions in the header, derive them from the first frame
     if (header.animatedVersion == AnimatedVersion_TEXS0001 || header.animatedVersion == AnimatedVersion_TEXS0002) {
 	header.gifWidth = (*header.frames.begin ())->width1;
 	header.gifHeight = (*header.frames.begin ())->height1;
     }
 
-    // Calculate spritesheet grid dimensions from animation frames
-    // Spritesheets are grid-based textures where each frame is at a specific position
+    // spritesheets are grid-based; infer the grid from texture size vs. frame size
     if (!header.frames.empty () && header.width > 0 && header.height > 0) {
 	auto& firstFrame = *header.frames.front ();
 	float frameWidth = firstFrame.width1;
@@ -285,8 +277,8 @@ void TextureParser::parseAnimations (Texture& header, const BinaryReader& file)
 		= static_cast<uint32_t> (std::round (static_cast<double> (header.height) / frameHeight));
 	    const uint32_t frameCount = static_cast<uint32_t> (header.frames.size ());
 
-	    // Only populate spritesheet metadata if the inferred grid can actually hold all frames
-	    // This prevents GIFs (where frameWidth == textureWidth) from being treated as 1×1 spritesheets
+	    // only accept the grid if it can hold all frames - otherwise plain GIFs (frameWidth == textureWidth)
+	    // would get treated as 1x1 spritesheets
 	    if (cols > 0 && rows > 0 && cols * rows >= frameCount) {
 		header.spritesheetCols = cols;
 		header.spritesheetRows = rows;
@@ -361,10 +353,8 @@ TextureUniquePtr TextureParser::parse (
     const BinaryReader& file, const std::string& filename,
     std::function<std::string (const std::string&)> metadataLoader
 ) {
-    // Parse the binary .tex file first
     auto result = parse (file);
 
-    // Try to load optional .tex-json metadata for spritesheet data
     if (metadataLoader) {
 	parseSpritesheetMetadata (*result, filename, metadataLoader);
     }
@@ -379,7 +369,6 @@ void TextureParser::parseSpritesheetMetadata (
 	std::string texJsonContent = metadataLoader (filename + ".tex-json");
 	nlohmann::json texJson = nlohmann::json::parse (texJsonContent);
 
-	// Check for spritesheet sequences
 	if (texJson.contains ("spritesheetsequences") && texJson["spritesheetsequences"].is_array ()) {
 	    auto& sequences = texJson["spritesheetsequences"];
 	    if (!sequences.empty ()) {
@@ -390,7 +379,6 @@ void TextureParser::parseSpritesheetMetadata (
 		float duration = firstSeq.value ("duration", 1.0f);
 
 		if (frames > 0 && frameWidth > 0.0f && frameHeight > 0.0f && header.width > 0 && header.height > 0) {
-		    // Calculate grid dimensions from texture size and frame size
 		    header.spritesheetCols = static_cast<uint32_t> (std::round (header.width / frameWidth));
 		    header.spritesheetRows = static_cast<uint32_t> (std::round (header.height / frameHeight));
 		    header.spritesheetFrames = static_cast<uint32_t> (frames);

+ 2 - 2
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -36,8 +36,8 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	}
     }
 
-    // TODO: This might need to be removed if it causes issues with default values
-    // Connect to property if one is specified (this allows property overrides to propagate)
+    // TODO: might need removing if it causes issues with default values
+    // connect to property so overrides can propagate
     if (property != nullptr) {
 	if (condition.has_value ()) {
 	    value->attachCondition (condition.value ());

+ 1 - 2
src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp

@@ -30,8 +30,7 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
     const auto objects = scene.require ("objects", "Scenes must have an objects section");
     const auto& properties = project.properties;
 
-    // TODO: FIND IF THESE DEFAULTS ARE SENSIBLE OR NOT AND PERFORM PROPER VALIDATION WHEN CAMERA PREVIEW AND CAMERA
-    // PARALLAX ARE PRESENT
+    // TODO: verify these defaults are sensible and validate when camera preview/parallax are present
 
     return std::make_unique <Scene> (
         WallpaperData {

+ 0 - 2
src/WallpaperEngine/Data/Utils/SFINAE.h

@@ -4,12 +4,10 @@
 #include <glm/detail/type_vec1.hpp>
 
 namespace WallpaperEngine::Data::Utils::SFINAE {
-// sfinae to detect the type for template specialization
 template <typename T> struct is_glm_vec : std::false_type { };
 
 template <glm::length_t L, typename S, glm::qualifier Q> struct is_glm_vec<glm::vec<L, S, Q>> : std::true_type { };
 
-// traits used to guess the type of the vector and it's length
 template <typename T> struct GlmVecTraits;
 
 template <glm::length_t L, typename T, glm::qualifier Q> struct GlmVecTraits<glm::vec<L, T, Q>> {

+ 0 - 4
src/WallpaperEngine/FileSystem/Adapters/Package.cpp

@@ -14,7 +14,6 @@ using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::FileSystem::Adapters;
 
 ReadStreamSharedPtr PackageAdapter::open (const std::filesystem::path& path) const {
-    // find the file entry
     const auto it = std::ranges::find_if (this->package->files, [&path] (const auto& file) {
 	return file->filename == path.string ();
     });
@@ -23,14 +22,11 @@ ReadStreamSharedPtr PackageAdapter::open (const std::filesystem::path& path) con
 	throw std::filesystem::filesystem_error ("Cannot find file", path, std::error_code ());
     }
 
-    // read file into memory
     auto buffer = std::make_unique<char[]> (it->get ()->length);
 
-    // go to the file's position and read into the buffer
     this->package->file->base ().seekg (it->get ()->offset + this->package->baseOffset, std::ios::beg);
     this->package->file->next (buffer.get (), it->get ()->length);
 
-    // create a memory stream and return that
     return std::make_shared<MemoryStream> (std::move (buffer), it->get ()->length);
 }
 

+ 2 - 5
src/WallpaperEngine/FileSystem/Container.cpp

@@ -14,9 +14,8 @@ using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::FileSystem::Adapters;
 
 /**
- * Normalizes a file path to get rid of relative stuff as most as possible
- * This is not a security measure but helps keep adapters that do not really have an actual filesystem
- * behind to trust the input data without much validation
+ * Normalizes a path to strip relative components; not a security measure, just lets adapters
+ * with no real filesystem behind them trust the input without much validation.
  * @see https://en.cppreference.com/w/cpp/filesystem/path/lexically_normal
  */
 std::filesystem::path normalize_path (const std::filesystem::path& input_path) {
@@ -24,7 +23,6 @@ std::filesystem::path normalize_path (const std::filesystem::path& input_path) {
 }
 
 Container::Container () {
-    // register all available factories
     this->m_factories.push_back (std::make_unique<VirtualFactory> ());
     this->m_factories.push_back (std::make_unique<PackageFactory> ());
     this->m_factories.push_back (std::make_unique<DirectoryFactory> ());
@@ -59,7 +57,6 @@ std::filesystem::path Container::physicalPath (const std::filesystem::path& path
 }
 
 AdapterSharedPtr Container::mount (const std::filesystem::path& path, const std::filesystem::path& mountPoint) {
-    // check if any adapter can handle the path
     for (const auto& factory : this->m_factories) {
 	if (factory->handlesMountpoint (path) == false) {
 	    continue;

+ 0 - 2
src/WallpaperEngine/Input/Drivers/GLFWMouseInput.cpp

@@ -19,14 +19,12 @@ void GLFWMouseInput::update () {
     this->m_leftClick = leftClickState == GLFW_RELEASE ? MouseClickStatus::Released : MouseClickStatus::Clicked;
     this->m_rightClick = rightClickState == GLFW_RELEASE ? MouseClickStatus::Released : MouseClickStatus::Clicked;
 
-    // update current mouse position
     glfwGetCursorPos (this->m_driver.getWindow (), &this->m_mousePosition.x, &this->m_mousePosition.y);
 
     // Convert from GLFW coordinate system (Y=0 at top) to OpenGL coordinate system (Y=0 at bottom)
     const glm::ivec2 framebufferSize = this->m_driver.getFramebufferSize ();
     this->m_mousePosition.y = static_cast<double> (framebufferSize.y) - this->m_mousePosition.y;
 
-    // interpolate to the new position
     this->m_reportedPosition = glm::mix (this->m_reportedPosition, this->m_mousePosition, 1.0);
 }
 

+ 0 - 20
src/WallpaperEngine/Input/Drivers/GLFWMouseInput.h

@@ -9,39 +9,19 @@ class GLFWOpenGLDriver;
 }
 
 namespace WallpaperEngine::Input::Drivers {
-/**
- * Handles mouse input for the background
- */
 class GLFWMouseInput final : public MouseInput {
 public:
     explicit GLFWMouseInput (const Render::Drivers::GLFWOpenGLDriver& driver);
 
-    /**
-     * Takes current mouse position and updates it
-     */
     void update () override;
 
-    /**
-     * The virtual pointer's position
-     */
     [[nodiscard]] glm::dvec2 position () const override;
-
-    /**
-     * @return The status of the mouse's left click
-     */
     [[nodiscard]] MouseClickStatus leftClick () const override;
-
-    /**
-     * @return The status of the mouse's right click
-     */
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 private:
     const Render::Drivers::GLFWOpenGLDriver& m_driver;
 
-    /**
-     * The current mouse position
-     */
     glm::dvec2 m_mousePosition = {};
     glm::dvec2 m_reportedPosition = {};
     MouseClickStatus m_leftClick = Released;

+ 0 - 20
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h

@@ -20,31 +20,14 @@ class WaylandOpenGLDriver;
 };
 
 namespace WallpaperEngine::Input::Drivers {
-/**
- * Handles mouse input for the background
- */
 class WaylandMouseInput final : public MouseInput {
 public:
     explicit WaylandMouseInput (const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& driver);
 
-    /**
-     * Takes current mouse position and updates it
-     */
     void update () override;
 
-    /**
-     * The virtual pointer's position
-     */
     [[nodiscard]] glm::dvec2 position () const override;
-
-    /**
-     * @return The status of the mouse's left click
-     */
     [[nodiscard]] MouseClickStatus leftClick () const override;
-
-    /**
-     * @return The status of the mouse's right click
-     */
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 private:
@@ -57,9 +40,6 @@ private:
 #endif /* ENABLE_X11 */
     bool matchViewport (const glm::dvec2& globalCursor);
 
-    /**
-     * Wayland: Driver
-     */
     const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& m_waylandDriver;
 
     glm::dvec2 m_pos = {};

+ 0 - 3
src/WallpaperEngine/Input/InputContext.h

@@ -11,9 +11,6 @@ class InputContext {
 public:
     explicit InputContext (MouseInput& mouseInput);
 
-    /**
-     * Updates input information
-     */
     void update ();
 
     [[nodiscard]] const MouseInput& getMouseInput () const;

+ 0 - 14
src/WallpaperEngine/Input/MouseInput.h

@@ -20,24 +20,10 @@ enum MouseClickStatus : int { Released = 0, Clicked = 1 };
 class MouseInput {
 public:
     virtual ~MouseInput () = default;
-    /**
-     * Takes current mouse position and updates it
-     */
     virtual void update () = 0;
 
-    /**
-     * The virtual pointer's position
-     */
     [[nodiscard]] virtual glm::dvec2 position () const = 0;
-
-    /**
-     * @return The status of the mouse's left click
-     */
     [[nodiscard]] virtual MouseClickStatus leftClick () const = 0;
-
-    /**
-     * @return The status of the mouse's right click
-     */
     [[nodiscard]] virtual MouseClickStatus rightClick () const = 0;
 };
 } // namespace WallpaperEngine::Input

+ 5 - 12
src/WallpaperEngine/Logging/Log.h

@@ -18,9 +18,8 @@ public:
     template <typename... Data> void out (Data... data) {
 	std::string str = this->buildBuffer (data...);
 
-	// then send it to all the outputs configured
 	for (const auto cur : this->mOutputs) {
-	    *cur << str << std::endl;
+	    *cur << str << '\n' << std::flush;
 	}
     }
 
@@ -28,9 +27,8 @@ public:
 #if (!NDEBUG) && (!ERRORONLY)
 	std::string str = this->buildBuffer (data...);
 
-	// then send it to all the outputs configured
 	for (const auto cur : this->mOutputs) {
-	    *cur << str << std::endl;
+	    *cur << str << '\n';
 	}
 #endif /* DEBUG */
     }
@@ -39,9 +37,8 @@ public:
 #if (!NDEBUG) && (ERRORONLY)
 	std::string str = this->buildBuffer (data...);
 
-	// then send it to all the outputs configured
 	for (const auto cur : this->mOutputs) {
-	    *cur << str << std::endl;
+	    *cur << str << '\n';
 	}
 #endif /* DEBUG */
     }
@@ -49,20 +46,17 @@ public:
     template <typename... Data> void error (Data... data) {
 	std::string str = this->buildBuffer (data...);
 
-	// then send it to all the outputs configured
 	for (const auto cur : this->mErrors) {
-	    *cur << str << std::endl;
+	    *cur << str << '\n' << std::flush;
 	}
     }
 
     template <class EX, typename... Data> [[noreturn]] void exception (Data... data) {
 	std::string str = this->buildBuffer (data...);
-	// then send it to all the outputs configured
 	for (const auto cur : this->mErrors) {
-	    *cur << str << std::endl;
+	    *cur << str << '\n';
 	}
 
-	// now throw the exception
 	throw EX (str);
     }
 
@@ -76,7 +70,6 @@ private:
     Log ();
 
     template <typename... Data> std::string buildBuffer (Data... data) {
-	// buffer the string first
 	std::stringbuf buffer;
 	std::ostream bufferStream (&buffer);
 

+ 0 - 1
src/WallpaperEngine/Media/DBusMediaSource.cpp

@@ -220,7 +220,6 @@ void DBusMediaSource::parsePosition (DBusMessageIter& variant) {
 void DBusMediaSource::update () {
     this->MediaSource::update ();
 
-    // drain any dbus events
     dbus_connection_read_write (this->m_connection, 0);
 
     while (dbus_connection_dispatch (this->m_connection) == DBUS_DISPATCH_DATA_REMAINS)

+ 1 - 9
src/WallpaperEngine/Render/AlbumTexture.cpp

@@ -27,7 +27,6 @@ stbi_io_callbacks album_texture_callbacks
     = { .read = albumtexture_read, .skip = albumtexture_skip, .eof = albumtexture_eof };
 
 AlbumTexture::AlbumTexture (RenderContext& context) : Helpers::ContextAware (context) {
-    // setup a basic texture with clamping and no mipmaps
     this->m_resolution = glm::vec4 (1.0f, 1.0f, 1.0f, 1.0f);
 
     glGenTextures (1, &this->m_textureID);
@@ -67,17 +66,14 @@ void AlbumTexture::decrementUsageCount () const { }
 void AlbumTexture::update () const { }
 
 void AlbumTexture::copyContents (const TextureProvider& other) const noexcept {
-    // fallback to gpu -> cpu -> gpu copy
-    // RGBA8 texture: 4 bytes per pixel
+    // gpu -> cpu -> gpu fallback copy; RGBA8 texture, 4 bytes per pixel
     size_t bufferSize = other.getTextureWidth (0) * other.getTextureHeight (0) * 4;
 
     uint8_t* buffer = new uint8_t[bufferSize];
 
-    // Read the source texture
     glBindTexture (GL_TEXTURE_2D, other.getTextureID (0));
     glGetnTexImage (GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, bufferSize, buffer);
 
-    // Upload into another texture
     glBindTexture (GL_TEXTURE_2D, this->m_textureID);
     glTexImage2D (
 	GL_TEXTURE_2D, 0, GL_RGBA8, other.getTextureWidth (0), other.getTextureHeight (0), 0, GL_RGBA, GL_UNSIGNED_BYTE,
@@ -86,7 +82,6 @@ void AlbumTexture::copyContents (const TextureProvider& other) const noexcept {
 
     delete[] buffer;
 
-    // copy over the important metadata
     this->m_width = other.getTextureWidth (0);
     this->m_height = other.getTextureHeight (0);
     this->m_resolution = *other.getResolution ();
@@ -98,7 +93,6 @@ void AlbumTexture::load () const {
 
     for (const auto& project : this->getContext ().getApp ().getBackgrounds () | std::views::values) {
 	try {
-	    // try to open the file in any of the asset locators
 	    auto contents = project->assetLocator->read ("$mediaThumbnail");
 
 	    int width, height, channels;
@@ -120,7 +114,6 @@ void AlbumTexture::load () const {
 	    this->m_height = height;
 	    this->m_resolution = glm::vec4 (this->m_width, this->m_height, this->m_width, this->m_height);
 
-	    // setup texture contents
 	    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
 	    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, dataptr);
 	    return;
@@ -131,6 +124,5 @@ void AlbumTexture::load () const {
 }
 
 bool AlbumTexture::isReady () const {
-    // these are only ready to be rendered if their content's are present
     return this->m_width > 0 && this->m_height > 0;
 }

+ 4 - 6
src/WallpaperEngine/Render/CFBO.cpp

@@ -22,10 +22,8 @@ CFBO::CFBO (
     } else if (flags & TextureFlags_ClampUVsBorder) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
-	// Without this the border color defaults to transparent black, which combined with
-	// GL_LINEAR filtering smears/blends into the last edge texel right at the boundary -
-	// this makes out-of-bounds areas (Center/Fit letterboxing, zoomed-out scaling) a clean
-	// solid color instead.
+	// without this the border defaults to transparent black, which GL_LINEAR filtering smears
+	// into the last edge texel; this keeps out-of-bounds areas (letterboxing, zoomed-out scaling) solid
 	glTexParameterfv (GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, &borderColor.x);
     } else {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
@@ -49,8 +47,8 @@ CFBO::CFBO (
 	sLog.exception ("Framebuffers are not properly set");
     }
 
-    // Layer framebuffers must start transparent. The scene clear color is often opaque,
-    // and using it here makes empty layer areas render as solid rectangles.
+    // must start transparent: the scene clear color is often opaque and would make empty layer
+    // areas render as solid rectangles
     GLfloat previousClearColor[4] = {};
     glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
     glClearColor (0.0f, 0.0f, 0.0f, 0.0f);

+ 14 - 20
src/WallpaperEngine/Render/CTexture.cpp

@@ -12,17 +12,25 @@ using namespace WallpaperEngine::Render;
 
 CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
     Helpers::ContextAware (context), m_header (std::move (header)) {
-    // ensure the header is parsed
     this->setupResolution ();
     const GLint internalFormat = this->setupInternalFormat ();
 
-    // videos are a bit special, they only have one framebuffer, one mipmap
+    GLint maxTextureSize = 0;
+    glGetIntegerv (GL_MAX_TEXTURE_SIZE, &maxTextureSize);
+    if (this->m_header->textureWidth > static_cast<uint32_t> (maxTextureSize)
+        || this->m_header->textureHeight > static_cast<uint32_t> (maxTextureSize)) {
+        sLog.error (
+            "Texture ", this->m_header->textureWidth, "x", this->m_header->textureHeight,
+            " exceeds this GL context's GL_MAX_TEXTURE_SIZE (", maxTextureSize, "); it will fail to upload"
+        );
+    }
+
+    // videos only get one framebuffer and one mipmap
     if (this->m_header->isVideoMp4 || this->m_header->flags & TextureFlags_Video) {
 	if (this->m_header->images.empty () || this->m_header->images.begin ()->second.empty ()) {
 	    sLog.exception ("Cannot load video texture, no mipmaps found");
 	}
 
-	// generate the texture and set it up to be used by the player
 	this->m_textureID = new GLuint[1];
 	glGenTextures (1, this->m_textureID);
 	this->setupOpenGLParameters (0);
@@ -34,17 +42,13 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 	    std::make_unique<MemoryStreamProtocol> (mipmap->uncompressedData.get (), mipmap->uncompressedSize),
 	    this->m_header->textureWidth, this->m_header->textureHeight
 	);
-	// setup texture video player
 	this->m_player->setMuted ();
 	this->m_player->setVolume (0.0f);
 	this->m_player->setUntimed ();
-	// texture is ready, nothing else to do
 	return;
     }
 
-    // allocate texture ids list
     this->m_textureID = new GLuint[this->m_header->imageCount];
-    // ask opengl for the correct amount of textures and framebuffers
     glGenTextures (this->m_header->imageCount, this->m_textureID);
 
     for (const auto& [index, mipmaps] : this->m_header->images) {
@@ -69,7 +73,7 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 		);
 	    } else {
 		if (this->m_header->format == TextureFormat_R8) {
-		    // red textures are 1-byte-per-pixel, so it's alignment has to be set manually
+		    // 1 byte per pixel, so alignment must be set manually
 		    glPixelStorei (GL_UNPACK_ALIGNMENT, 1);
 		    textureFormat = GL_RED;
 		} else if (this->m_header->format == TextureFormat_RG88) {
@@ -96,7 +100,6 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 		    sLog.exception ("Cannot load texture, unknown format", this->m_header->format);
 	    }
 
-	    // stbi_image buffer won't be used anymore, so free memory
 	    if (this->m_header->freeImageFormat != FIF_UNKNOWN) {
 		stbi_image_free (handle);
 	    }
@@ -107,7 +110,7 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 }
 
 CTexture::~CTexture () {
-    // first release the player to prevent using null references
+    // release the player first so nothing else keeps using it via null references
     this->m_player.reset ();
 
     if (this->m_header->isVideoMp4 || this->m_header->flags & TextureFlags_Video) {
@@ -126,14 +129,11 @@ void CTexture::setupResolution () {
     } else {
 	if (this->m_header->freeImageFormat != FIF_UNKNOWN) {
 	    // wpengine-texture format always has one mipmap
-	    // get first image size
 	    const auto element = this->m_header->images.find (0)->second.begin ();
 
-	    // set the texture resolution
 	    this->m_resolution
 		= { (*element)->width, (*element)->height, this->m_header->width, this->m_header->height };
 	} else {
-	    // set the texture resolution
 	    this->m_resolution = { this->m_header->textureWidth, this->m_header->textureHeight, this->m_header->width,
 				   this->m_header->height };
 	}
@@ -145,7 +145,6 @@ GLint CTexture::setupInternalFormat () const {
 	return GL_RGBA8;
     }
 
-    // detect the image format and hand it to openGL to be used
     switch (this->m_header->format) {
 	case TextureFormat_DXT5:
 	    return GL_COMPRESSED_RGBA_S3TC_DXT5_EXT;
@@ -165,15 +164,12 @@ GLint CTexture::setupInternalFormat () const {
 }
 
 void CTexture::setupOpenGLParameters (const uint32_t textureID) const {
-    // TODO: LABEL ELEMENTS TOO
-    // bind the texture to assign information to it
+    // TODO: label elements too
     glBindTexture (GL_TEXTURE_2D, this->m_textureID[textureID]);
 
-    // set mipmap levels
     glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
     glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, this->m_header->images[textureID].size () - 1);
 
-    // setup texture wrapping and filtering
     if (this->m_header->flags & TextureFlags_ClampUVs) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
@@ -194,7 +190,6 @@ void CTexture::setupOpenGLParameters (const uint32_t textureID) const {
 }
 
 GLuint CTexture::getTextureID (const uint32_t imageIndex) const {
-    // ensure we do not go out of bounds
     if (imageIndex >= this->m_header->imageCount) {
 	return this->m_textureID[0];
     }
@@ -264,5 +259,4 @@ void CTexture::update () const {
     }
 }
 
-// CTextures are always ready to be rendered at all times
 bool CTexture::isReady () const { return true; }

+ 2 - 31
src/WallpaperEngine/Render/CTexture.h

@@ -40,52 +40,23 @@ public:
     [[nodiscard]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] float getSpritesheetDuration () const override;
 
-    /**
-     * Increments the usage count of the texture
-     *
-     * Directly controls playback for video CTextures, only started when at least one thing is using it
-     * Initializes mpv if needed and starts playback
-     */
+    /** For video CTextures, playback only starts once usage count goes above zero (initializes mpv if needed) */
     void incrementUsageCount () const override;
-    /**
-     * Decrements the usage count of the texture
-     *
-     * Directly controls playback for video CTextures, only stopped when nothing is using it
-     * De-initializes mpv if needed
-     */
+    /** For video CTextures, playback only stops once usage count reaches zero (de-initializes mpv if needed) */
     void decrementUsageCount () const override;
-    /**
-     * Some textures need to be updated
-     */
     void update () const override;
     bool isReady () const override;
 
 private:
-    /**
-     * @return The texture header
-     */
     [[nodiscard]] const Texture& getHeader () const;
 
-    /**
-     * Calculate's texture's resolution vec4
-     */
     void setupResolution ();
-    /**
-     * Determines the texture's internal storage format
-     */
     GLint setupInternalFormat () const;
-    /**
-     * Prepares openGL parameters for loading texture data
-     */
     void setupOpenGLParameters (uint32_t textureID) const;
 
-    /** The texture header */
     TextureUniquePtr m_header;
-    /** OpenGL's texture ID */
     GLuint* m_textureID = nullptr;
-    /** Resolution vector of the texture */
     glm::vec4 m_resolution {};
-    /** The video player in use */
     GLPlayerUniquePtr m_player;
 };
 } // namespace WallpaperEngine::Assets

+ 5 - 9
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -177,7 +177,6 @@ void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
 void CWallpaper::render (
     const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition, const glm::ivec2& logicalSize
 ) {
-    // Get current frame counter from the driver to avoid redundant scene renders
     const uint32_t currentFrame = this->getContext ().getDriver ().getFrameCounter ();
     const bool needsSceneRender = (currentFrame != this->m_lastRenderedFrame);
     const glm::ivec4 sceneViewport = this->m_spanInfo.has_value ()
@@ -199,26 +198,25 @@ void CWallpaper::render (
     float ustart, uend, vstart, vend;
 
     if (this->m_spanInfo.has_value ()) {
-	// Span mode: treat bounding box as virtual viewport, scale wallpaper using
-	// the normal scaling rules (fill/fit/stretch/default), then slice per monitor.
+	// span mode: scale the wallpaper to the bounding box using the normal scaling rules
+	// (fill/fit/stretch/default), then slice per monitor
 	const auto& span = this->m_spanInfo.value ();
 	const float spanW = static_cast<float> (span.totalBounds.z);
 	const float spanH = static_cast<float> (span.totalBounds.w);
 	const float spanX = static_cast<float> (span.totalBounds.x);
 	const float spanY = static_cast<float> (span.totalBounds.y);
 
-	// Compute base UVs for the wallpaper scaled to the bounding box
 	this->updateUVs (span.totalBounds, vflip);
 	auto [baseUstart, baseUend, baseVstart, baseVend] = this->m_state.getTextureUVs ();
 
-	// This viewport's relative position within the bounding box [0..1]
-	// Use logicalSize (same coordinate space as globalPosition and totalBounds)
+	// this viewport's relative position within the bounding box [0..1]; logicalSize is in the
+	// same coordinate space as globalPosition and totalBounds
 	const float relLeft = (static_cast<float> (globalPosition.x) - spanX) / spanW;
 	const float relRight = (static_cast<float> (globalPosition.x + logicalSize.x) - spanX) / spanW;
 	const float relTop = (static_cast<float> (globalPosition.y) - spanY) / spanH;
 	const float relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
 
-	// Interpolate within the base UVs to get this viewport's slice
+	// interpolate within the base UVs to get this viewport's slice
 	const float baseURange = baseUend - baseUstart;
 	const float baseVRange = baseVend - baseVstart;
 
@@ -227,7 +225,6 @@ void CWallpaper::render (
 	vstart = baseVstart + relTop * baseVRange;
 	vend = baseVstart + relBottom * baseVRange;
 
-	// Log span debug info only on first few frames
 	if (this->m_lastRenderedFrame < 5) {
 	    sLog.debug (
 		"SPAN DEBUG: viewport=", viewport.z, "x", viewport.w, " globalPos=(", globalPosition.x, ",",
@@ -238,7 +235,6 @@ void CWallpaper::render (
 	    );
 	}
     } else {
-	// Normal mode: compute UVs based on viewport dimensions and wallpaper resolution
 	updateUVs (viewport, vflip);
 	auto uvs = this->m_state.getTextureUVs ();
 	ustart = uvs.ustart;

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

@@ -8,8 +8,7 @@ using namespace WallpaperEngine::Render;
 
 Camera::Camera (Wallpapers::CScene& scene, const SceneData::Camera& camera) :
     m_width (0), m_height (0), m_camera (camera), m_scene (scene) {
-    // get the lookat position
-    // TODO: ENSURE THIS IS ONLY USED WHEN NOT DOING AN ORTOGRAPHIC CAMERA AS IT THROWS OFF POINTS
+    // TODO: ensure this is only used for non-orthographic cameras, it throws off points otherwise
     this->m_lookat = glm::lookAt (this->getEye (), this->getCenter (), this->getUp ());
 }
 

+ 1 - 9
src/WallpaperEngine/Render/Drivers/Detectors/FullScreenDetector.h

@@ -8,17 +8,9 @@ public:
     explicit FullScreenDetector (Application::ApplicationContext& appContext);
     virtual ~FullScreenDetector () = default;
 
-    /**
-     * @return If anything is fullscreen
-     */
     [[nodiscard]] virtual bool anythingFullscreen () const;
-    /**
-     * Restarts the fullscreen detector, specially useful if there's any resources tied to the output driver
-     */
+    /** Resets the detector, useful when resources tied to the output driver need to be released */
     virtual void reset ();
-    /**
-     * @return The application context using this detector
-     */
     [[nodiscard]] Application::ApplicationContext& getApplicationContext () const;
 
 private:

+ 13 - 98
src/WallpaperEngine/Render/Drivers/Detectors/KDEWaylandFullScreenDetector.h

@@ -12,62 +12,33 @@
 namespace WallpaperEngine::Render::Drivers::Detectors {
 
 /**
- * @brief KDE Plasma/KWin fullscreen and maximize detector using D-Bus.
+ * KDE Plasma/KWin fullscreen and maximize detector using D-Bus.
  *
  * Registers itself as a D-Bus service on the session bus so that a companion
- * KWin script can call @c OnWindowChanged whenever a window's maximize or
- * fullscreen state changes. Incoming notifications are stored per-window and
- * queried by @c anythingFullscreen() to decide whether the wallpaper engine
- * should pause rendering.
+ * KWin script can call OnWindowChanged whenever a window's maximize or
+ * fullscreen state changes.
  *
- * If D-Bus initialization fails, @c m_connection is left as @c nullptr and
- * the caller is expected to fall back to @c WaylandFullScreenDetector instead.
- *
- * @note Only compiled when @c ENABLE_WAYLAND and @c ENABLE_KDE_EXPERIMENTAL_FEATURES are defined.
+ * If D-Bus initialization fails, m_connection is left null and the caller is
+ * expected to fall back to WaylandFullScreenDetector instead.
  */
 class KDEWaylandFullScreenDetector final : public FullScreenDetector {
 public:
     explicit KDEWaylandFullScreenDetector (Application::ApplicationContext& appContext);
-
-    /**
-     * @brief Destructor. Releases the session-bus connection.
-     */
     ~KDEWaylandFullScreenDetector () override;
 
     /**
-     * @brief Returns @c true if any relevant window is currently fullscreen or
-     *        fully maximized.
-     *
-     * Queries the window-state map populated by @c OnWindowChanged calls from
-     * the KWin script. If no notifications have arrived (e.g. no window is
-     * fullscreen, or no state has changed since startup), returns @c false.
-     *
-     * When @c pauseOnFullscreenOnlyWhenActive is set in the render settings,
-     * only the most-recently-activated window is examined; otherwise all
-     * tracked windows are checked.
-     *
-     * App IDs listed in @c fullscreenPauseIgnoreAppIds are excluded from
-     * consideration (substring match).
-     *
-     * @return @c true if at least one non-ignored fully-maximized or
-     *         fullscreen window exists under the current pause policy.
+     * True if a non-ignored window is fullscreen/fully maximized, per the window-state map
+     * populated by OnWindowChanged. When pauseOnFullscreenOnlyWhenActive is set, only the
+     * most-recently-activated window is examined; app IDs in fullscreenPauseIgnoreAppIds
+     * are excluded (substring match).
      */
     [[nodiscard]] bool anythingFullscreen () const override;
 
     [[nodiscard]] bool isInitialized () const;
 
-    /**
-     * @brief Clears all cached window states.
-     *
-     * After this call @c anythingFullscreen() will return @c false until the
-     * next @c OnWindowChanged notification arrives.
-     */
     void reset () override;
 
 private:
-    /**
-     * @brief Snapshot of the maximize/fullscreen state for a single window.
-     */
     struct WindowState {
 	bool horizontal = false; ///< Window spans the full horizontal extent of its output.
 	bool vertical = false; ///< Window spans the full vertical extent of its output.
@@ -75,83 +46,27 @@ private:
 	std::string appId {}; ///< Wayland app-id / desktop file name, used for ignore-list matching.
     };
 
-    /**
-     * @brief Static C-linkage trampoline required by the libdbus object-path vtable.
-     *
-     * Casts @p userData back to a @c KDEWaylandFullScreenDetector pointer and
-     * delegates to the non-static overload.
-     *
-     * @param connection  The D-Bus connection that received the message.
-     * @param message     The incoming D-Bus message.
-     * @param userData    Pointer to the owning @c KDEWaylandFullScreenDetector instance.
-     * @return @c DBUS_HANDLER_RESULT_HANDLED if the message was consumed,
-     *         @c DBUS_HANDLER_RESULT_NOT_YET_HANDLED otherwise.
-     */
+    /** C-linkage trampoline required by the libdbus object-path vtable; forwards to the instance overload. */
     static DBusHandlerResult handleMessage (DBusConnection* connection, DBusMessage* message, void* userData);
 
-    /**
-     * @brief Instance-level message handler. Filters by message type and
-     *        interface name before forwarding to @c handleMethodCall().
-     *
-     * @param message  The incoming D-Bus message.
-     * @return @c DBUS_HANDLER_RESULT_HANDLED if the message was consumed,
-     *         @c DBUS_HANDLER_RESULT_NOT_YET_HANDLED otherwise.
-     */
     DBusHandlerResult handleMessage (DBusMessage* message);
 
-    /**
-     * @brief Connects to the session D-Bus, requests the service name, and
-     *        registers the object path.
-     *
-     * @return @c true on success; @c false if any D-Bus operation fails, in
-     *         which case the connection is released and @c m_connection is left
-     *         as @c nullptr.
-     */
     bool initializeDBus ();
-
-    /**
-     * @brief Releases the D-Bus connection.
-     */
     void stopDBus ();
 
     /**
-     * @brief Parses and handles an @c OnWindowChanged method-call message.
-     *
-     * Expected arguments:
-     *   - @c STRING  windowKey
-     *   - @c STRING  windowName
-     *   - @c INT32   pid
-     *   - @c STRING  appId
-     *   - @c BOOLEAN horizontal
-     *   - @c BOOLEAN vertical
-     *   - @c BOOLEAN fully
-     *
-     * @param message  A D-Bus method-call message.
-     * @return @c true if the message was successfully parsed and handled.
+     * Parses an OnWindowChanged method call. Expected args, in order:
+     * STRING windowKey, STRING windowName, INT32 pid, STRING appId,
+     * BOOLEAN horizontal, BOOLEAN vertical, BOOLEAN fully.
      */
     bool handleMethodCall (DBusMessage* message);
 
-    /**
-     * @brief Inserts or replaces the @c WindowState entry for @p windowKey
-     *        and updates the active-window key.
-     *
-     * @param windowKey   Opaque string identifier for the window.
-     * @param appId       Wayland app-id used for ignore-list matching.
-     * @param horizontal  Whether the window occupies the full screen width.
-     * @param vertical    Whether the window occupies the full screen height.
-     * @param fully       Whether the window is fully maximized or fullscreen.
-     */
     bool updateWindowState (
 	const std::string& windowKey, const std::string& appId, bool horizontal, bool vertical, bool fully
     );
 
-    /** @brief Session D-Bus connection handle; @c nullptr if initialization failed. */
     mutable DBusConnection* m_connection = nullptr;
-
-    /** @brief Per-window maximize/fullscreen state, keyed by the window's opaque identifier. */
     mutable std::unordered_map<std::string, WindowState> m_windowStates;
-
-    /** @brief Key of the most recently activated (focused) window. */
     mutable std::string m_activeWindowKey;
 
     static constexpr const char* kServiceName = "org.linuxwallpaperengine.WaylandDetector";

+ 4 - 12
src/WallpaperEngine/Render/Drivers/Detectors/X11FullScreenDetector.cpp

@@ -13,7 +13,6 @@ void CustomXIOErrorExitHandler (Display* dsp, void* userdata) {
 
     sLog.debugerror ("Critical XServer error detected. Attempting to recover...");
 
-    // refetch all the resources
     context->reset ();
 }
 
@@ -32,17 +31,14 @@ int CustomXIOErrorHandler (Display* dsp) {
 X11FullScreenDetector::X11FullScreenDetector (Application::ApplicationContext& appContext, VideoDriver& driver) :
     FullScreenDetector (appContext), m_display (nullptr), m_root (0), m_driver (driver) {
     try {
-	// attempt casting to CGLFWOpenGLDriver, this will throw if it's not possible
-	// so we can gracely handle the error
+	// throws if m_driver isn't actually a GLFWOpenGLDriver, so we can catch the misuse
 	std::ignore = dynamic_cast<GLFWOpenGLDriver&> (this->m_driver);
     } catch (std::exception&) {
 	sLog.exception ("X11 FullScreen Detector initialized with the wrong video driver... This is a bug...");
     }
 
-    // do not use previous handler, it might stop the app under weird circumstances
-    // these handlers might be replaced by other X11-specific functionality, they
-    // should only be used to ignore X11 errors and nothing else
-    // so this doesn't affect functionality
+    // not chaining the previous handler: these only need to ignore X11 errors, and chaining
+    // could stop the app under weird circumstances
     XSetErrorHandler (CustomXErrorHandler);
     XSetIOErrorHandler (CustomXIOErrorHandler);
 
@@ -52,7 +48,6 @@ X11FullScreenDetector::X11FullScreenDetector (Application::ApplicationContext& a
 X11FullScreenDetector::~X11FullScreenDetector () { this->stop (); }
 
 bool X11FullScreenDetector::anythingFullscreen () const {
-    // stop rendering if anything is fullscreen
     bool isFullscreen = false;
     XWindowAttributes attribs;
     Window _;
@@ -84,7 +79,6 @@ bool X11FullScreenDetector::anythingFullscreen () const {
 	    continue;
 	}
 
-	// ignore ourselves
 	if (ourWindow == children[i] || parentWindow == children[i]) {
 	    continue;
 	}
@@ -93,7 +87,6 @@ bool X11FullScreenDetector::anythingFullscreen () const {
 	    continue;
 	}
 
-	// compare width and height with the different screens we have
 	for (const auto& [name, viewport] : this->m_screens) {
 	    if (attribs.x == viewport.x && attribs.y == viewport.y && attribs.width == viewport.z
 		&& attribs.height == viewport.w) {
@@ -116,7 +109,7 @@ void X11FullScreenDetector::reset () {
 void X11FullScreenDetector::initialize () {
     this->m_display = XOpenDisplay (nullptr);
 
-    // set the error handling to try and recover from X disconnections
+    // recover from X disconnections instead of aborting
 #ifdef HAVE_XSETIOERROREXITHANDLER
     XSetIOErrorExitHandler (this->m_display, CustomXIOErrorExitHandler, this);
 #endif /* HAVE_XSETIOERROREXITHANDLER */
@@ -151,7 +144,6 @@ void X11FullScreenDetector::initialize () {
 	    continue;
 	}
 
-	// add the screen to the list of screens
 	this->m_screens.emplace (std::string (info->name), glm::ivec4 (crtc->x, crtc->y, crtc->width, crtc->height));
 
 	XRRFreeCrtcInfo (crtc);

+ 7 - 21
src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp

@@ -21,22 +21,21 @@ GLFWOpenGLDriver::GLFWOpenGLDriver (const char* windowTitle, ApplicationContext&
     VideoDriver (app, m_mouseInput), m_context (context), m_mouseInput (*this) {
     glfwSetErrorCallback (CustomGLFWErrorHandler);
 
-    // initialize glfw
     if (glfwInit () == GLFW_FALSE) {
 	sLog.exception ("Failed to initialize glfw");
     }
 
-    // set some window hints (opengl version to be used)
     glfwWindowHint (GLFW_SAMPLES, 4);
     glfwWindowHint (GLFW_CONTEXT_VERSION_MAJOR, 3);
     glfwWindowHint (GLFW_CONTEXT_VERSION_MINOR, 3);
     glfwWindowHint (GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
+    // required for glDebugMessageCallback (WallpaperApplication::setupOpenGLDebugging) on drivers that
+    // only emit KHR_debug output when the context is created with this flag
+    glfwWindowHint (GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
     glfwWindowHint (GLFW_VISIBLE, GLFW_FALSE);
-    // set X11-specific hints
     glfwWindowHintString (GLFW_X11_CLASS_NAME, "linux-wallpaperengine");
     glfwWindowHintString (GLFW_X11_INSTANCE_NAME, "linux-wallpaperengine");
 
-    // for forced window mode, we can set some hints that'll help position the window
     if (context.settings.render.mode == Application::ApplicationContext::EXPLICIT_WINDOW) {
 	glfwWindowHint (GLFW_RESIZABLE, GLFW_FALSE);
 	glfwWindowHint (GLFW_DECORATED, GLFW_FALSE);
@@ -47,22 +46,19 @@ GLFWOpenGLDriver::GLFWOpenGLDriver (const char* windowTitle, ApplicationContext&
     glfwWindowHint (GLFW_OPENGL_DEBUG_CONTEXT, GL_TRUE);
 #endif /* DEBUG */
 
-    // create window, size doesn't matter as long as we don't show it
+    // window stays hidden until shown, so the initial size here is irrelevant
     this->m_window = glfwCreateWindow (640, 480, windowTitle, nullptr, nullptr);
 
     if (this->m_window == nullptr) {
 	sLog.exception ("Cannot create window");
     }
 
-    // make context current, required for glew initialization
     glfwMakeContextCurrent (this->m_window);
 
-    // initialize glew for rendering
     if (const GLenum result = glewInit (); result != GLEW_OK) {
 	sLog.error ("Failed to initialize GLEW: ", glewGetErrorString (result));
     }
 
-    // setup output
     if (context.settings.render.mode == ApplicationContext::EXPLICIT_WINDOW
 	|| context.settings.render.mode == ApplicationContext::NORMAL_WINDOW) {
 	m_output = new WallpaperEngine::Render::Drivers::Output::GLFWWindowOutput (context, *this);
@@ -109,25 +105,21 @@ uint32_t GLFWOpenGLDriver::getFrameCounter () const { return this->m_frameCounte
 
 void GLFWOpenGLDriver::dispatchEventQueue () {
     static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
-    // get the start time of the frame
     startTime = this->getRenderTime ();
-    // clear the screen
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
     for (const auto& [screen, viewport] : this->m_output->getViewports ()) {
 	this->getApp ().update (viewport);
     }
 
-    // read the full texture into the image
     if (this->m_output->haveImageBuffer ()) {
-	// 4.5 supports glReadnPixels, anything older doesn't...
+	// glReadnPixels requires GL 4.5; older drivers fall back to glReadPixels
 	if (GLEW_VERSION_4_5) {
 	    glReadnPixels (
 		0, 0, this->m_output->getFullWidth (), this->m_output->getFullHeight (), GL_BGRA, GL_UNSIGNED_BYTE,
 		this->m_output->getImageBufferSize (), this->m_output->getImageBuffer ()
 	    );
 	} else {
-	    // fallback to old version
 	    glReadPixels (
 		0, 0, this->m_output->getFullWidth (), this->m_output->getFullHeight (), GL_BGRA, GL_UNSIGNED_BYTE,
 		this->m_output->getImageBuffer ()
@@ -141,20 +133,14 @@ void GLFWOpenGLDriver::dispatchEventQueue () {
 	}
     }
 
-    // TODO: FRAMETIME CONTROL SHOULD GO BACK TO THE CWALLPAPAERAPPLICATION ONCE ACTUAL PARTICLES ARE IMPLEMENTED
-    // TODO: AS THOSE, MORE THAN LIKELY, WILL REQUIRE OF A DIFFERENT PROCESSING RATE
-    // update the output with the given image
+    // TODO: frametime control should go back to CWallpaperApplication once actual particles are
+    // implemented, as those will likely require a different processing rate
     this->m_output->updateRender ();
-    // do buffer swapping first
     glfwSwapBuffers (this->m_window);
-    // poll for events
     glfwPollEvents ();
-    // increase frame counter
     this->m_frameCounter++;
-    // get the end time of the frame
     endTime = this->getRenderTime ();
 
-    // ensure the frame time is correct to not overrun FPS
     if ((endTime - startTime) < minimumTime) {
 	usleep ((minimumTime - (endTime - startTime)) * CLOCKS_PER_SEC);
     }

+ 2 - 9
src/WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.cpp

@@ -14,7 +14,6 @@ GLFWWindowOutput::GLFWWindowOutput (ApplicationContext& context, VideoDriver& dr
 	sLog.exception ("Initializing window output when not in output mode, how did you get here?!");
     }
 
-    // window should be visible
     driver.showWindow ();
 
     if (this->m_context.settings.render.mode == Application::ApplicationContext::EXPLICIT_WINDOW) {
@@ -22,18 +21,15 @@ GLFWWindowOutput::GLFWWindowOutput (ApplicationContext& context, VideoDriver& dr
 	this->m_fullHeight = this->m_context.settings.render.window.geometry.w;
 	this->repositionWindow ();
     } else {
-	// take the size from the driver (default window size)
 	this->m_fullWidth = this->m_driver.getFramebufferSize ().x;
 	this->m_fullHeight = this->m_driver.getFramebufferSize ().y;
     }
 
-    // register the default viewport
     this->m_viewports["default"]
 	= new GLFWOutputViewport { { 0, 0, this->m_fullWidth, this->m_fullHeight }, "default" };
 }
 
 void GLFWWindowOutput::repositionWindow () const {
-    // reposition the window
     this->m_driver.resizeWindow (this->m_context.settings.render.window.geometry);
 }
 
@@ -54,13 +50,10 @@ void* GLFWWindowOutput::getImageBuffer () const { return nullptr; }
 uint32_t GLFWWindowOutput::getImageBufferSize () const { return 0; }
 
 void GLFWWindowOutput::updateRender () const {
-    // Track the current framebuffer dimensions regardless of window mode so
-    // runtime resizes (EXPLICIT_WINDOW mode via resizeWindow, WM-initiated
-    // host window resize) re-stretch the scene across the new viewport
-    // instead of cropping the original render at the old size.
+    // re-read framebuffer size every frame so runtime resizes (EXPLICIT_WINDOW resizeWindow, or a
+    // WM-initiated resize) re-stretch the scene instead of cropping the render at the old size
     this->m_fullWidth = this->m_driver.getFramebufferSize ().x;
     this->m_fullHeight = this->m_driver.getFramebufferSize ().y;
 
-    // update the default viewport
     this->m_viewports["default"]->viewport = { 0, 0, this->m_fullWidth, this->m_fullHeight };
 }

+ 0 - 7
src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h

@@ -20,14 +20,7 @@ public:
     /** Whether this viewport is single in the framebuffer or shares space with more viewports */
     bool single;
 
-    /**
-     * Activates output's context for drawing
-     */
     virtual void makeCurrent () = 0;
-
-    /**
-     * Swaps buffers to present data on the viewport
-     */
     virtual void swapOutput () = 0;
 };
 } // namespace WallpaperEngine::Render::Drivers::Output

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

@@ -50,7 +50,7 @@ static void geometry (
 static void mode (void* data, wl_output* output, uint32_t flags, int32_t width, int32_t height, int32_t refresh) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
 
-    // update viewport size (physical pixels; logicalSize comes from xdg-output or layer shell configure)
+    // physical pixels; logicalSize comes from xdg-output or the layer shell configure
     viewport->size = { width, height };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
 
@@ -86,7 +86,6 @@ static void name (void* data, wl_output* wl_output, const char* name) {
 	viewport->name = name;
     }
 
-    // ensure the output is updated with the new name too
     viewport->getDriver ()->getOutput ().reset ();
 }
 
@@ -153,7 +152,6 @@ constexpr struct zxdg_output_v1_listener xdgOutputListener = {
 WaylandOutputViewport::WaylandOutputViewport (
     WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry
 ) : OutputViewport ({ 0, 0, 0, 0 }, "", true), size ({ 0, 0 }), waylandName (waylandName), m_driver (driver) {
-    // setup output listener
     this->output = static_cast<wl_output*> (wl_registry_bind (registry, waylandName, &wl_output_interface, 4));
     wl_output_add_listener (output, &outputListener, this);
 }
@@ -197,9 +195,8 @@ void WaylandOutputViewport::setupLS () {
 	wl_region_add (region, 0, 0, INT32_MAX, INT32_MAX);
     }
 
-    // Mark the surface as fully opaque so the compositor can skip rendering
-    // anything below it and avoid alpha-blending. Wallpapers are by definition
-    // the bottommost visible content, so this is always a win.
+    // fully opaque: lets the compositor skip alpha-blending, always a win since wallpapers are the
+    // bottommost visible content
     wl_region* opaqueRegion = wl_compositor_create_region (m_driver->getWaylandContext ()->compositor);
     wl_region_add (opaqueRegion, 0, 0, INT32_MAX, INT32_MAX);
     wl_surface_set_opaque_region (surface, opaqueRegion);

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

@@ -32,9 +32,6 @@ namespace Output {
     public:
 	WaylandOutputViewport (WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry);
 
-	/**
-	 * @return The wayland driver
-	 */
 	WaylandOpenGLDriver* getDriver () const;
 
 	wl_output* output = nullptr;
@@ -66,19 +63,8 @@ namespace Output {
 	void setupLS ();
 	void setupXdgOutput (zxdg_output_manager_v1* manager);
 
-	/**
-	 * Activates output's context for drawing
-	 */
 	void makeCurrent () override;
-
-	/**
-	 * Swaps buffers to present data on the viewport
-	 */
 	void swapOutput () override;
-
-	/**
-	 * Updates the viewport size
-	 */
 	void resize ();
 
     private:

+ 6 - 23
src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp

@@ -15,7 +15,6 @@ void CustomXIOErrorExitHandler (Display* dsp, void* userdata) {
 
     sLog.debugerror ("Critical XServer error detected. Attempting to recover...");
 
-    // refetch all the resources
     context->reset ();
 }
 
@@ -34,7 +33,7 @@ int CustomXIOErrorHandler (Display* dsp) {
 X11Output::X11Output (ApplicationContext& context, VideoDriver& driver) :
     Output (context, driver), m_display (nullptr), m_pixmap (None), m_root (None), m_gc (None), m_imageData (nullptr),
     m_imageSize (0), m_image (nullptr) {
-    // do not use previous handler, it might stop the app under weird circumstances
+    // not chaining the previous handler: it could stop the app under weird circumstances
     XSetErrorHandler (CustomXErrorHandler);
     XSetIOErrorHandler (CustomXIOErrorHandler);
 
@@ -44,17 +43,13 @@ X11Output::X11Output (ApplicationContext& context, VideoDriver& driver) :
 X11Output::~X11Output () { this->free (); }
 
 void X11Output::reset () {
-    // first free whatever we have right now
     this->free ();
-    // re-load screen info
     this->loadScreenInfo ();
-    // do the same for the detector
-    // TODO: BRING BACK THIS FUNCTIONALITY
-    // this->m_driver.getFullscreenDetector ().reset ();
+    // TODO: bring back resetting the fullscreen detector here
 }
 
 void X11Output::free () {
-    // delete owned viewport objects (m_viewports holds non-owning aliases)
+    // m_viewports holds non-owning aliases, m_screens owns the objects
     for (const auto& screen : this->m_screens) {
 	delete screen;
     }
@@ -62,7 +57,6 @@ void X11Output::free () {
     this->m_screens.clear ();
     this->m_viewports.clear ();
 
-    // free all the resources we've got
     // XDestroyImage() already frees m_imageData itself (via its default destroy_image proc, which
     // calls XFree()/free() on the buffer XCreateImage() was given) - it must not be freed again here
     XDestroyImage (this->m_image);
@@ -84,7 +78,7 @@ uint32_t X11Output::getImageBufferSize () const { return this->m_imageSize; }
 
 void X11Output::loadScreenInfo () {
     this->m_display = XOpenDisplay (nullptr);
-    // set the error handling to try and recover from X disconnections
+    // recover from X disconnections instead of aborting
 #ifdef HAVE_XSETIOERROREXITHANDLER
     XSetIOErrorExitHandler (this->m_display, CustomXIOErrorExitHandler, this);
 #endif /* HAVE_XSETIOERROREXITHANDLER */
@@ -128,7 +122,6 @@ void X11Output::discoverOutputs (XRRScreenResources* screenResources) {
 	    continue;
 	}
 
-	// check if this screen is part of a span group
 	bool inSpanGroup = false;
 	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
 	    for (const auto& screen : spanGroup.screens) {
@@ -142,7 +135,6 @@ void X11Output::discoverOutputs (XRRScreenResources* screenResources) {
 	    }
 	}
 
-	// only keep info of registered screens
 	if (inSpanGroup
 	    || this->m_context.settings.general.screenBackgrounds.find (info->name)
 		!= this->m_context.settings.general.screenBackgrounds.end ()) {
@@ -173,7 +165,6 @@ void X11Output::validateOutputs () const {
 	    break;
 	}
 
-	// also check span groups
 	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
 	    for (const auto& screen : spanGroup.screens) {
 		if (screen == o->name) {
@@ -209,35 +200,27 @@ void X11Output::validateOutputs () const {
 }
 
 void X11Output::initX11Background () {
-    // create pixmap so we can draw things in there
     this->m_pixmap = XCreatePixmap (this->m_display, this->m_root, this->m_fullWidth, this->m_fullHeight, 24);
     this->m_gc = XCreateGC (this->m_display, this->m_pixmap, 0, nullptr);
-    // pre-fill it with black
     XFillRectangle (this->m_display, this->m_pixmap, this->m_gc, 0, 0, this->m_fullWidth, this->m_fullHeight);
-    // set the window background as our pixmap
     XSetWindowBackgroundPixmap (this->m_display, this->m_root, this->m_pixmap);
-    // allocate space for the image's data
     // XCreateImage() takes ownership of this buffer and frees it itself (via free()) when the
     // XImage is destroyed, so it must be allocated with malloc(), not new[]
     this->m_imageSize = this->m_fullWidth * this->m_fullHeight * 4;
     this->m_imageData = static_cast<char*> (malloc (this->m_imageSize));
-    // create an image so we can copy it over
     this->m_image = XCreateImage (
 	this->m_display, CopyFromParent, 24, ZPixmap, 0, this->m_imageData, this->m_fullWidth, this->m_fullHeight, 32, 0
     );
-    // setup driver's render changing the window's size
     this->m_driver.resizeWindow ({ this->m_fullWidth, this->m_fullHeight });
 }
 
 void X11Output::updateRender () const {
-    // put the image back into the screen
     XPutImage (
 	this->m_display, this->m_pixmap, this->m_gc, this->m_image, 0, 0, 0, 0, this->m_fullWidth, this->m_fullHeight
     );
 
-    // _XROOTPMAP_ID & ESETROOT_PMAP_ID allow other programs (compositors) to
-    // edit the background. Without these, other programs will clear the screen.
-    // it also forces the compositor to refresh the background (tested with picom)
+    // _XROOTPMAP_ID/ESETROOT_PMAP_ID let other programs (compositors) know about the background
+    // pixmap instead of clearing it, and forces a compositor refresh (tested with picom)
     const Atom prop_root = XInternAtom (this->m_display, "_XROOTPMAP_ID", False);
     const Atom prop_esetroot = XInternAtom (this->m_display, "ESETROOT_PMAP_ID", False);
     XChangeProperty (

+ 0 - 42
src/WallpaperEngine/Render/Drivers/VideoDriver.h

@@ -26,63 +26,21 @@ public:
     explicit VideoDriver (WallpaperApplication& app, Input::MouseInput& mouseInput);
     virtual ~VideoDriver () = default;
 
-    /**
-     * @return The current output in use
-     */
     [[nodiscard]] virtual Output::Output& getOutput () = 0;
-    /**
-     * @return The time that has passed since the driver started
-     */
     [[nodiscard]] virtual float getRenderTime () const = 0;
-    /**
-     * @return If a close was requested by the OS
-     */
     virtual bool closeRequested () = 0;
-    /**
-     * @param size The new size for the window
-     */
     virtual void resizeWindow (glm::ivec2 size) = 0;
-    /**
-     * @param positionAndSize The new size and position of the window
-     */
     virtual void resizeWindow (glm::ivec4 positionAndSize) = 0;
-    /**
-     * Shows the window created by the driver
-     */
     virtual void showWindow () = 0;
-    /**
-     * Hides the window created by the driver
-     */
     virtual void hideWindow () = 0;
-    /**
-     * @return The size of the framebuffer available for the driver
-     */
     [[nodiscard]] virtual glm::ivec2 getFramebufferSize () const = 0;
-    /**
-     * @return The number of rendered frames since the start of the driver
-     */
     [[nodiscard]] virtual uint32_t getFrameCounter () const = 0;
-    /**
-     * @param name
-     * @return GetProcAddress for this video driver
-     */
     [[nodiscard]] virtual void* getProcAddress (const char* name) const = 0;
-    /**
-     * Process events on the driver and renders a frame
-     */
     virtual void dispatchEventQueue () = 0;
-    /**
-     * @return The app that owns this driver
-     */
     [[nodiscard]] WallpaperApplication& getApp () const;
-
-    /**
-     * @return The input context in use by this driver
-     */
     [[nodiscard]] Input::InputContext& getInputContext ();
 
 private:
-    /** App that owns this driver */
     WallpaperApplication& m_app;
     Input::InputContext m_inputContext;
 };

+ 2 - 3
src/WallpaperEngine/Render/Drivers/VideoFactories.cpp

@@ -64,9 +64,8 @@ std::unique_ptr<VideoDriver> VideoFactories::createVideoDriver (
 	sLog.exception ("Cannot find a driver for window mode ", mode, " and XDG_SESSION_TYPE ", xdgSessionType);
     }
 
-    // windows are a bit special, there's just one handler
-    // and it's not like the current map properly allows for storing this
-    // so hijacking the detection is probably best for now
+    // windowed modes only have one handler and the map isn't built to store that, so just
+    // hijack session-type detection with a fixed key for those
     const auto factory = mode != Application::ApplicationContext::DESKTOP_BACKGROUND
 	? sessionTypeToFactory->second.find (DEFAULT_WINDOW_NAME)
 	: sessionTypeToFactory->second.find (xdgSessionType);

+ 1 - 30
src/WallpaperEngine/Render/Drivers/VideoFactories.h

@@ -20,49 +20,20 @@ public:
 
     static VideoFactories& get ();
 
-    /**
-     * Adds a new handler for the given window mode and XDG_SESSION_TYPE
-     *
-     * @param forMode
-     * @param xdgSessionType
-     * @param factory
-     */
     void registerDriver (
 	ApplicationContext::WINDOW_MODE forMode, std::string xdgSessionType, DriverConstructionFunc factory
     );
 
-    /**
-     * Adds a new handler for the given XDG_SESSION_TYPE
-     *
-     * @param xdgSessionType
-     * @param factory
-     */
     void registerFullscreenDetector (std::string xdgSessionType, FullscreenDetectorConstructionFunc factory);
 
-    /**
-     * @return List of drivers supported over all the different window modes
-     */
     [[nodiscard]] std::vector<std::string> getRegisteredDrivers () const;
 
-    /**
-     * Calls the factory and builds the requested video driver
-     *
-     * @param mode
-     * @param xdgSessionType
-     * @param context
-     * @param application
-     * @return
-     */
     [[nodiscard]] std::unique_ptr<VideoDriver> createVideoDriver (
 	ApplicationContext::WINDOW_MODE mode, const std::string& xdgSessionType, ApplicationContext& context,
 	WallpaperApplication& application
     );
 
-    /**
-     * Calls the factory and builds the requested fullscreen detector or provides a stub if not possible
-     *
-     * @return
-     */
+    /** Falls back to a no-op FullScreenDetector when no factory is registered for xdgSessionType */
     [[nodiscard]] std::unique_ptr<Detectors::FullScreenDetector>
     createFullscreenDetector (const std::string& xdgSessionType, ApplicationContext& context, VideoDriver& driver);
 

+ 13 - 24
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -58,7 +58,7 @@ static void handlePointerMotion (
 	return;
     }
 
-    // Convert from Wayland coordinate system (Y=0 at top) to OpenGL coordinate system (Y=0 at bottom)
+    // Wayland has Y=0 at the top, OpenGL has Y=0 at the bottom
     const double viewportHeight = static_cast<double> (driver->viewportInFocus->size.y);
     y = viewportHeight - y;
 
@@ -140,9 +140,8 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 static void handleGlobalRemoved (void* data, struct wl_registry* registry, uint32_t id) {
     const auto driver = static_cast<WaylandOpenGLDriver*> (data);
 
-    // find the viewport bound to the removed global (e.g. a monitor being unplugged/disabled) -
-    // leaving it around leaks its layer-shell/EGL surfaces in the compositor for the rest of this
-    // client's connection, since nothing else will ever ask it to disconnect
+    // a monitor being unplugged/disabled removes its global; leaving the viewport around leaks its
+    // layer-shell/EGL surfaces for the rest of this client's connection since nothing else disconnects it
     const auto it = std::ranges::find_if (
 	driver->m_screens, [id] (const auto* viewport) { return viewport->waylandName == id; }
     );
@@ -230,6 +229,10 @@ void WaylandOpenGLDriver::initEGL () {
 	3,
 	EGL_CONTEXT_OPENGL_PROFILE_MASK_KHR,
 	EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR,
+	// required for glDebugMessageCallback (WallpaperApplication::setupOpenGLDebugging) on drivers that
+	// only emit KHR_debug output when the context is created with this flag; Mesa tends to be lenient
+	EGL_CONTEXT_FLAGS_KHR,
+	EGL_CONTEXT_OPENGL_DEBUG_BIT_KHR,
 	EGL_NONE,
     };
 
@@ -294,13 +297,8 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	wl_output_release (viewport->output);
     }
 
-    // remove the output from the list
     std::erase (this->m_screens, viewport);
-
-    // reset the viewports
     this->getOutput ().reset ();
-
-    // finally free memory used by the viewport
     delete viewport;
 }
 
@@ -331,7 +329,6 @@ void WaylandOpenGLDriver::initWaylandRegistry () {
 	sLog.exception ("Failed to bind to required interfaces");
     }
 
-    // If xdg-output-manager is available, use it to get logical output positions
     if (m_waylandContext.xdgOutputManager) {
 	for (const auto& o : this->m_screens) {
 	    o->setupXdgOutput (m_waylandContext.xdgOutputManager);
@@ -348,7 +345,6 @@ void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
     for (const auto& o : this->m_screens) {
 	bool shouldSetup = m_context.settings.general.screenBackgrounds.contains (o->name);
 
-	// also check if this screen is in any span group
 	if (!shouldSetup) {
 	    for (const auto& spanGroup : m_context.settings.general.spanGroups) {
 		for (const auto& screen : spanGroup.screens) {
@@ -399,9 +395,8 @@ void WaylandOpenGLDriver::initGLEW () {
     glewExperimental = GL_TRUE;
     if (const GLenum result = glewInit (); result != GLEW_OK) {
 	const char* error = reinterpret_cast<const char*> (glewGetErrorString (result));
-	// On Wayland+EGL, GLEW may fail with GLEW_ERROR_NO_GLX_DISPLAY or an unrecognised
-	// code (null string) when the build also includes X11 but no GLX display is present.
-	// Both are non-fatal: the EGL context is already current and GLEW extensions still load.
+	// on Wayland+EGL, GLEW may report GLEW_ERROR_NO_GLX_DISPLAY or a null string when the build
+	// also includes X11 but no GLX display is present; non-fatal, the EGL context is already current
 	if (result == GLEW_ERROR_NO_GLX_DISPLAY || error == nullptr) {
 	    sLog.out ("Failed to initialize GLEW, but continuing with EGL context: ",
 		      error ? error : "No GLX display");
@@ -413,7 +408,6 @@ void WaylandOpenGLDriver::initGLEW () {
 }
 
 WaylandOpenGLDriver::~WaylandOpenGLDriver () {
-    // destroy xdg outputs
     for (const auto& screen : this->m_screens) {
 	if (screen->xdgOutput) {
 	    zxdg_output_v1_destroy (screen->xdgOutput);
@@ -421,7 +415,6 @@ WaylandOpenGLDriver::~WaylandOpenGLDriver () {
 	}
     }
 
-    // stop EGL
     eglMakeCurrent (EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
 
     if (m_eglContext.context != EGL_NO_CONTEXT) {
@@ -431,7 +424,6 @@ WaylandOpenGLDriver::~WaylandOpenGLDriver () {
     eglTerminate (m_eglContext.display);
     eglReleaseThread ();
 
-    // disconnect from wayland display
     if (this->m_waylandContext.display) {
 	wl_display_disconnect (this->m_waylandContext.display);
     }
@@ -448,13 +440,11 @@ void WaylandOpenGLDriver::dispatchEventQueue () {
 	}
     }
 
-    // TODO: FRAMETIME CONTROL SHOULD GO BACK TO THE CWALLPAPAERAPPLICATION ONCE ACTUAL PARTICLES ARE IMPLEMENTED
-    // TODO: AS THOSE, MORE THAN LIKELY, WILL REQUIRE OF A DIFFERENT PROCESSING RATE
-
-    // TODO: WRITE A NON-BLOCKING VERSION OF THIS ONCE PARTICLE SIMULATION STARTS WORKING
-    // TODO: OTHERWISE wl_display_dispatch WILL BLOCK IF NO SURFACES ARE BEING DRAWN
+    // TODO: frametime control should go back to CWallpaperApplication once actual particles are
+    // implemented, as those will likely require a different processing rate
+    // TODO: write a non-blocking version of this once particle simulation starts working, otherwise
+    // wl_display_dispatch will block if no surfaces are being drawn
     static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
-    // get the start time of the frame
     startTime = this->getRenderTime ();
 
     if (wl_display_dispatch (m_waylandContext.display) == -1) {
@@ -465,7 +455,6 @@ void WaylandOpenGLDriver::dispatchEventQueue () {
 
     endTime = this->getRenderTime ();
 
-    // ensure the frame time is correct to not overrun FPS
     if ((endTime - startTime) < minimumTime) {
 	usleep ((minimumTime - (endTime - startTime)) * CLOCKS_PER_SEC);
     }

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

@@ -87,15 +87,11 @@ public:
     [[nodiscard]] SEGLContext* getEGLContext ();
     [[nodiscard]] WaylandContext* getWaylandContext ();
 
-    /** List of available screens */
     std::vector<Output::WaylandOutputViewport*> m_screens = {};
 
 private:
-    /** The output used by the driver */
     Output::WaylandOutput m_output;
-    /** The EGL context in use */
     SEGLContext m_eglContext = {};
-    /** The Wayland context in use */
     WaylandContext m_waylandContext = {};
     mutable bool m_requestedExit;
 

+ 1 - 1
src/WallpaperEngine/Render/FBOProvider.cpp

@@ -8,7 +8,7 @@ FBOProvider::FBOProvider (const FBOProvider* parent) : m_parent (parent) { }
 std::shared_ptr<CFBO> FBOProvider::create (const FBO& base, uint32_t flags, const glm::vec2 size) {
     return this->m_fbos[base.name] = std::make_shared<CFBO> (
 	       base.name,
-	       // TODO: PROPERLY DETERMINE FBO FORMAT BASED ON THE STRING
+	       // TODO: properly determine FBO format based on the string
 	       TextureFormat_ARGB8888, flags, base.scale, size.x / base.scale, size.y / base.scale, size.x / base.scale,
 	       size.y / base.scale
 	   );

+ 0 - 9
src/WallpaperEngine/Render/Helpers/ContextAware.h

@@ -11,19 +11,10 @@ namespace Helpers {
     class ContextAware {
     public:
 	virtual ~ContextAware () = default;
-	/**
-	 * @param from Object to get the render context from
-	 */
 	ContextAware (const ContextAware& from);
-	/**
-	 * @param from Object to get the render context from
-	 */
 	explicit ContextAware (const ContextAware* from);
 	explicit ContextAware (RenderContext& context);
 
-	/**
-	 * @return The CRenderContext in use right now
-	 */
 	[[nodiscard]] RenderContext& getContext () const;
 
     private:

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 770 - 96
src/WallpaperEngine/Render/Objects/CImage.cpp


+ 79 - 5
src/WallpaperEngine/Render/Objects/CImage.h

@@ -10,8 +10,12 @@
 #include "../TextureProvider.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
+#include <glm/mat4x4.hpp>
 #include <glm/vec3.hpp>
+#include <glm/vec4.hpp>
 #include <limits>
+#include <optional>
+#include <set>
 #include <vector>
 
 using namespace WallpaperEngine;
@@ -23,6 +27,46 @@ class CPass;
 } // namespace WallpaperEngine::Render::Objects::Effects
 
 namespace WallpaperEngine::Render::Objects {
+/** A puppet skeleton bone, parsed from the MDLS section of the puppet .mdl */
+struct PuppetBone {
+    int parent = -1;
+    /** Local bind-pose transform, relative to the parent bone (identity for a root bone's "world" reference) */
+    glm::mat4 bindLocal { 1.0f };
+    /** Inverse of the bone's bind-pose world transform, derived by walking the parent chain */
+    glm::mat4 inverseBindWorld { 1.0f };
+};
+
+/** A single sampled TRS pose for one bone at one point in time, from the MDLA section */
+struct PuppetKeyframe {
+    glm::vec3 position {};
+    glm::vec3 rotation {};
+    glm::vec3 scale { 1.0f };
+};
+
+/** A baked animation clip: one keyframe track per bone, sampled at a fixed rate */
+struct PuppetAnimationClip {
+    std::string name;
+    std::string mode;
+    float fps = 30.0f;
+    uint32_t frameCount = 0;
+    /** [boneIndex][sampleIndex], each track has frameCount+1 samples */
+    std::vector<std::vector<PuppetKeyframe>> boneTracks;
+};
+
+/** A named point on a puppet's rig that other objects can follow via scene.json's "attachment" field */
+struct PuppetAttachmentPoint {
+    std::string name;
+    int boneIndex = -1;
+    /** Transform of the point relative to its bone, in the same convention as PuppetBone::bindLocal */
+    glm::mat4 localTransform { 1.0f };
+};
+
+/** One of a puppet's animationlayers[] entries, paired with the baked clip it plays */
+struct PuppetActiveAnimation {
+    PuppetAnimationClip clip;
+    const WallpaperEngine::Data::Model::ImageAnimationLayer* layer = nullptr;
+};
+
 class CImage final : public CRenderable, public ScriptableObject {
     friend CObject;
 
@@ -49,13 +93,15 @@ public:
     [[nodiscard]] const glm::vec4& getColor4 () const override;
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
 
+    void pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput);
+
     /**
-     * Performs a ping-pong on the available framebuffers to be able to continue rendering things to them
-     *
-     * @param drawTo The framebuffer to use
-     * @param asInput The last texture used as output (if needed)
+     * @param name A named attachment point on this puppet's rig (see PuppetAttachmentPoint)
+     * @return The point's current animated position, in this puppet's own local mesh space (the same
+     *         space puppet vertex positions are in before the size.x/2 +/- canvas-centering step) - or
+     *         nullopt if there's no such point (or no puppet mesh)
      */
-    void pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput);
+    [[nodiscard]] std::optional<glm::vec3> getAttachmentPointMeshPosition (const std::string& name) const;
 
 protected:
     void setupPasses ();
@@ -79,6 +125,8 @@ protected:
 private:
     bool loadPuppetMesh (const glm::vec2& size);
     void updatePuppetPositionBuffer (const glm::vec2& size);
+    /** Recomputes puppet vertex positions for the current animation time and re-uploads them */
+    void updatePuppetSkinning ();
     void setupPuppetGeometryCallback (Effects::CPass* pass) const;
     ResolvedTransform updateGeometryBuffers ();
     [[nodiscard]] glm::vec2 resolveGeometrySize (float sceneWidth, float sceneHeight, glm::vec3& origin) const;
@@ -103,7 +151,33 @@ private:
     GLuint m_puppetIndices = GL_NONE;
     GLsizei m_puppetIndexCount = 0;
     bool m_hasPuppetMesh = false;
+    mutable bool m_puppetDrawDiagnosticLogged = false;
+    mutable bool m_puppetDrawErrorChecked = false;
+    bool m_puppetPositionDiagnosticLogged = false;
+    bool m_transformDiagnosticLogged = false;
+    mutable std::set<int> m_attachmentDiagnosticLogged = {};
     std::vector<GLfloat> m_puppetRawPositions = {};
+    /** This object's current resolved scale, mirrored here so updatePuppetSkinning() (called after
+     *  updateGeometryBuffers() each frame, see render()) can fold it into puppet vertex positions
+     *  without needing resolveTransform() run twice */
+    glm::vec3 m_puppetScale { 1.0f };
+    std::vector<glm::uvec4> m_puppetBlendIndices = {};
+    std::vector<glm::vec4> m_puppetBlendWeights = {};
+
+    std::vector<PuppetBone> m_puppetBones = {};
+    /**
+     * Every animationlayers[] entry that matched a baked clip. Wallpaper Engine puppets almost always
+     * declare several "additive" layers (idle sway, blinking, hand movement, ...) that all play at once
+     * on top of each other rather than one layer replacing another - see updatePuppetSkinning for how
+     * they're composed.
+     */
+    std::vector<PuppetActiveAnimation> m_puppetActiveAnimations = {};
+    std::vector<GLfloat> m_puppetSkinnedPositions = {};
+
+    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 */
+    std::vector<glm::mat4> m_puppetBoneWorldAnimated = {};
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};

+ 88 - 203
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -27,11 +27,10 @@ CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
     this->registerProperty ("parallaxDepth", *particle.parallaxDepth->value);
 
     this->detectTexture ();
-    // Initialize random number generator with time-based seed
     std::random_device rd;
     m_rng.seed (rd ());
 
-    // Read renderer configuration early to determine rendering mode
+    // Read renderer config early - buffer sizing below depends on it
     if (!m_particle.renderers.empty ()) {
 	const auto& renderer = m_particle.renderers[0];
 	if (renderer.name == "rope" || renderer.name == "ropetrail") {
@@ -56,7 +55,6 @@ CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
 	}
     }
 
-    // Apply count instance override to particle pool size
     float countMultiplier = particle.instanceOverride.count->value->getFloat ();
     uint32_t adjustedMaxCount = static_cast<uint32_t> (particle.maxCount * countMultiplier);
 
@@ -65,16 +63,14 @@ CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
 
     m_particles.resize (m_maxParticles);
 
-    // Calculate buffer sizes based on renderer type
     if (m_useRopeRenderer) {
-	// Rope: connects N particles with (N-1) segments, each subdivided into sub-segments
+	// Rope: N particles connect via (N-1) segments, each subdivided into sub-segments
 	const int subdivision = std::max (1, m_ropeSubdivision);
 	const int maxSubSegments = std::max (1, static_cast<int> (m_maxParticles - 1)) * subdivision;
 	m_vertices.resize (maxSubSegments * 4 * ROPE_FLOATS_PER_VERTEX);
 	m_indices.resize (maxSubSegments * 6);
     } else {
-	// Trail particles: (N+1) * 2 vertices for ribbon strip, N * 6 indices for N quads
-	// Normal particles: 4 vertices, 6 indices
+	// 4 vertices, 6 indices per particle
 	const int verticesPerParticle = 4;
 	const int indicesPerParticle = 6;
 
@@ -105,9 +101,8 @@ void CParticle::setup () {
 	return;
     }
 
-    // Convert origin from screen space to centered space
-    // Projection uses ortho(-width/2, width/2, -height/2, height/2)
-    // but particle origins are in screen space where (0,0) is top-left
+    // Convert origin from screen space (0,0 top-left) to centered space, matching the
+    // ortho(-width/2, width/2, -height/2, height/2) projection
     m_lastScreenWidth = getScene ().getCamera ().getWidth ();
     m_lastScreenHeight = getScene ().getCamera ().getHeight ();
 
@@ -116,22 +111,20 @@ void CParticle::setup () {
     origin.y = m_lastScreenHeight / 2.0f - origin.y;
     m_transformedOrigin = origin;
 
-    // Load particle material constants
     if (m_particle.material && m_particle.material->material && !m_particle.material->material->passes.empty ()) {
 	auto& firstPass = *m_particle.material->material->passes.begin ();
 
-	// Read overbright constant (brightness multiplier for additive particles)
+	// Overbright: brightness multiplier for additive particles
 	auto overbrightIt = firstPass->constants.find ("ui_editor_properties_overbright");
 	if (overbrightIt != firstPass->constants.end ()) {
 	    m_overbright = overbrightIt->second->value->getFloat ();
 	}
     }
 
-    // Texture is resolved by CRenderable base class; read spritesheet data.
-    // TextureParser computes spritesheet grid from TEXS frame data (animated textures)
-    // or .tex-json metadata (static textures). For GIF-style animated textures (separate
-    // GL texture per frame), the parser returns 0 cols/rows since a 1x1 grid can't hold
-    // all frames — so no SPRITESHEET mode is needed (frame switching happens via texture ID).
+    // TextureParser computes the spritesheet grid from TEXS frame data (animated textures) or
+    // .tex-json metadata (static textures). GIF-style animated textures (separate GL texture per
+    // frame) get 0 cols/rows since a 1x1 grid can't hold all frames - no SPRITESHEET mode needed,
+    // frame switching happens via texture ID instead.
     if (const auto texture = getTexture ()) {
 	m_spritesheetCols = static_cast<int> (texture->getSpritesheetCols ());
 	m_spritesheetRows = static_cast<int> (texture->getSpritesheetRows ());
@@ -144,12 +137,11 @@ void CParticle::setup () {
     setupOperators ();
     setupPass ();
 
-    // Setup control points (max 8)
     m_controlPoints.resize (8);
     for (const auto& cp : m_particle.controlPoints) {
 	if (cp.id >= 0 && cp.id < 8) {
 	    m_controlPoints[cp.id].offset = cp.offset;
-	    // Link to mouse if either flags bit 0 is set
+	    // flags bit 0 = linkMouse
 	    m_controlPoints[cp.id].linkMouse = (cp.flags & 1) != 0;
 	    m_controlPoints[cp.id].worldSpace = (cp.flags & 2) != 0;
 
@@ -157,8 +149,7 @@ void CParticle::setup () {
 		m_hasMouseControlPoint = true;
 	    }
 
-	    // Initialize position to offset for non-mouse-linked control points
-	    // Mouse-linked CPs will have their position updated in update()
+	    // Mouse-linked CPs get their position from update() instead
 	    if (!m_controlPoints[cp.id].linkMouse) {
 		if (m_controlPoints[cp.id].worldSpace) {
 		    // World space: offset is in screen-centered coords, convert to particle local space
@@ -187,11 +178,10 @@ void CParticle::render () {
 
     const float currentTime = m_hasMouseControlPoint ? g_RealTime : g_Time;
 
-    // Initialize time on first render to avoid huge dt spike
+    // Initialize time on first render to avoid a huge dt spike, and skip the update
+    // that frame to avoid an initial burst
     if (m_time == 0.0) {
 	m_time = currentTime;
-	// Skip update on first frame to avoid weird initial burst
-	// This ensures all particles start from a clean state
 	if (m_useRopeRenderer) {
 	    renderRope ();
 	} else {
@@ -200,18 +190,15 @@ void CParticle::render () {
 	return;
     }
 
-    // Update particles
     float dt = currentTime - static_cast<float> (m_time);
     m_time = currentTime;
 
     if (dt > 0.0f) {
-	// Cap dt to prevent simulation instability
-	// Also provides more consistent behavior across different FPS
+	// Cap dt to prevent simulation instability across different FPS
 	dt = std::min (dt, 0.1f);
 	update (dt);
     }
 
-    // Render particles
     if (m_particleCount > 0 && m_particle.material) {
 	if (m_useRopeRenderer) {
 	    renderRope ();
@@ -222,7 +209,6 @@ void CParticle::render () {
 }
 
 void CParticle::update (float dt) {
-    // Detect resolution changes and recalculate transformed origin
     float screenWidth = static_cast<float> (getScene ().getWidth ());
     float screenHeight = static_cast<float> (getScene ().getHeight ());
 
@@ -237,7 +223,6 @@ void CParticle::update (float dt) {
 	for (size_t i = 0; i < m_controlPoints.size (); i++) {
 	    auto& cp = m_controlPoints[i];
 	    if (!cp.linkMouse && cp.worldSpace) {
-		// Recalculate position from offset using new transformed origin
 		cp.position = cp.offset - m_transformedOrigin;
 	    }
 	}
@@ -246,7 +231,6 @@ void CParticle::update (float dt) {
 	m_lastScreenHeight = screenHeight;
     }
 
-    // Update control points with mouse position
     const glm::vec2* mousePos = getScene ().getMousePositionNormalized ();
     if (mousePos) {
 
@@ -258,32 +242,27 @@ void CParticle::update (float dt) {
 		position.y = (screenHeight / 2.0f) - (mousePos->y * screenHeight);
 		position.z = 0.0f;
 
-		// Apply control point offset
 		position += cp.offset;
 
-		// Convert to particle local space to prevent double transformation by model matrix
-		// Both world-space and local-space CPs are handled the same way now
+		// Subtract transformed origin to keep in particle local space (avoids
+		// double transformation by the model matrix)
 		cp.position = position - m_transformedOrigin;
 	    }
 	}
     }
 
-    // Emit particles
     for (auto& emitter : m_emitters) {
 	emitter (m_particles, m_particleCount, dt);
     }
 
-    // Update particle age
     for (uint32_t i = 0; i < m_particleCount; i++) {
 	m_particles[i].age += dt;
     }
 
-    // Apply operators to living particles (including alphafade)
     for (auto& op : m_operators) {
 	op (m_particles, m_particleCount, m_controlPoints, static_cast<float> (m_time), dt);
     }
 
-    // Update animation frames
     for (uint32_t i = 0; i < m_particleCount; i++) {
 	auto& p = m_particles[i];
 
@@ -317,10 +296,8 @@ void CParticle::update (float dt) {
 	}
     }
 
-    // Remove dead particles with order-preserving compaction.
-    // Particles only die from natural lifetime expiry (age >= lifetime).
-    // We never kill based on size — particles may fade in/out with oscillating size.
-    // Compaction preserves spawn order so array index 0 is always the oldest particle.
+    // Order-preserving compaction: particles only die from lifetime expiry (never from
+    // size, since size can oscillate), and index 0 must stay the oldest particle
     uint32_t writeIdx = 0;
     for (uint32_t readIdx = 0; readIdx < m_particleCount; readIdx++) {
 	if (m_particles[readIdx].isAlive ()) {
@@ -409,13 +386,11 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		return;
 	    }
 
-	    // Handle delay
 	    if (delayTimer > 0.0f) {
 		delayTimer -= dt;
 		return;
 	    }
 
-	    // Handle duration
 	    if (emitter.duration > 0.0f) {
 		durationTimer += dt;
 		if (durationTimer >= emitter.duration) {
@@ -423,7 +398,6 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		}
 	    }
 
-	    // Handle random periodic emission
 	    if (randomPeriodicEmission) {
 		periodicTimer += dt;
 
@@ -449,16 +423,14 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		}
 	    }
 
-	    // TODO: Audio processing (audioProcessingMode, audioProcessingBounds, etc.)
+	    // TODO: audio processing (audioProcessingMode, audioProcessingBounds, etc.)
 
-	    // Handle instantaneous emission
 	    uint32_t toEmit = 0;
 	    if (emitter.instantaneous > 0 && !instantaneousEmitted) {
 		toEmit = emitter.instantaneous;
 		instantaneousEmitted = true;
 	    }
 
-	    // Rate-based emission with optional cap at 1 per frame
 	    if (emitter.rate > 0.0f) {
 		emissionTimer += dt * rate;
 		uint32_t rateEmit = static_cast<uint32_t> (emissionTimer);
@@ -470,7 +442,6 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		toEmit += rateEmit;
 	    }
 
-	    // Emit particles
 	    for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
 		auto& p = particles[count];
 
@@ -479,13 +450,11 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		    spawnOrigin += m_controlPoints[controlPointIndex].position;
 		}
 
-		// Generate random position within box volume centered on origin
-		// This creates a centered box (or hollow box if distanceMin > 0)
+		// Random position within the box volume (hollow box if distanceMin > 0)
 		glm::vec3 randomPos;
 		for (int axis = 0; axis < 3; axis++) {
 		    float minDist = emitter.distanceMin[axis];
 		    float maxDist = emitter.distanceMax[axis];
-		    // Generate value in [minDist, maxDist]
 		    float dist = WallpaperEngine::Maths::randomFloat (m_rng, minDist, maxDist);
 		    // Randomly flip sign to center the distribution
 		    if (WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) < 0.5f) {
@@ -522,7 +491,6 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		p.oscillateSize = {};
 		p.oscillatePosition = {};
 
-		// Apply initializers
 		for (auto& init : m_initializers) {
 		    init (p);
 		}
@@ -542,10 +510,10 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 
     int controlPointIndex = emitter.controlPoint;
 
-    // Auto-detect control point 0 usage if controlPoint field not specified and CP0 has linkMouse
+    // Auto-detect control point 0 if not specified and CP0 has linkMouse
     if (controlPointIndex == -1 && !m_particle.controlPoints.empty ()) {
 	const auto& cp0 = m_particle.controlPoints[0];
-	if ((cp0.flags & 1) != 0) { // Bit 0: linkMouse flag
+	if ((cp0.flags & 1) != 0) { // bit 0 = linkMouse
 	    controlPointIndex = 0;
 	}
     }
@@ -560,7 +528,6 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 	    return;
 	}
 
-	// Rate-based emission with optional cap at 1 per frame
 	emissionTimer += dt * rate;
 	uint32_t toEmit = static_cast<uint32_t> (emissionTimer);
 	emissionTimer -= static_cast<float> (toEmit);
@@ -577,19 +544,16 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 	for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
 	    auto& p = particles[count];
 
-	    // Determine spawn origin (control point or emitter origin)
 	    glm::vec3 spawnOrigin = transformedEmitterOrigin;
 	    if (controlPointIndex >= 0 && controlPointIndex < static_cast<int> (m_controlPoints.size ())) {
 		spawnOrigin += m_controlPoints[controlPointIndex].position;
 	    }
 
-	    // Spawn at random position on ellipsoid surface
 	    glm::vec3 randomPos;
 
-	    // Orthographic particles (flags & 4 == 0): use 2D disk distribution in X/Y plane
-	    // Perspective particles (flags & 4 != 0): use 3D spherical shell distribution
+	    // flags & 4 == 0: orthographic particles use a 2D disk distribution in X/Y
+	    // flags & 4 != 0: perspective particles use a 3D spherical shell distribution
 	    if ((m_particle.flags & 4) == 0) {
-		// 2D disk distribution with random Z offset
 		float angle = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
 		float minRadius = emitter.distanceMin.x;
 		float maxRadius = emitter.distanceMax.x;
@@ -606,7 +570,6 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 
 		randomPos *= emitter.directions;
 	    } else {
-		// 3D spherical shell distribution
 		float theta = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
 		float cosTheta = WallpaperEngine::Maths::randomFloat (m_rng, -1.0f, 1.0f);
 		float sinTheta = std::sqrt (1.0f - cosTheta * cosTheta);
@@ -624,15 +587,13 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 		randomPos *= emitter.directions;
 	    }
 
-	    // Apply sign property to force positive/negative values per axis
-	    // 0 = both, 1 = positive only, -1 = negative only
+	    // sign property forces per-axis polarity: 0 = both, 1 = positive only, -1 = negative only
 	    for (int i = 0; i < 3; i++) {
 		if (emitter.sign[i] == 1) {
-		    randomPos[i] = std::abs (randomPos[i]); // Force positive
+		    randomPos[i] = std::abs (randomPos[i]);
 		} else if (emitter.sign[i] == -1) {
-		    randomPos[i] = -std::abs (randomPos[i]); // Force negative
+		    randomPos[i] = -std::abs (randomPos[i]);
 		}
-		// If sign[i] == 0, leave as-is (both positive and negative possible)
 	    }
 	    p.position = spawnOrigin + randomPos;
 
@@ -644,7 +605,6 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 		float speed = WallpaperEngine::Maths::randomFloat (m_rng, emitter.speedMin, emitter.speedMax);
 		p.velocity = direction * speed;
 	    } else {
-		// No emitter speed specified, velocity will be set by initializers
 		p.velocity = glm::vec3 (0.0f);
 	    }
 
@@ -812,10 +772,7 @@ InitializerFunc CParticle::createAngularVelocityRandomInitializer (const Angular
 	glm::vec3 maxVec = maxValue->getVec3 ();
 	float exponent = exponentValue->getFloat ();
 
-	// Apply exponent bias to random distribution
-	// exponent = 1: uniform distribution
-	// exponent -> 0: bias towards max
-	// exponent >= 2: bias towards min
+	// exponent = 1: uniform; exponent -> 0: bias towards max; exponent >= 2: bias towards min
 	glm::vec3 result;
 	for (int i = 0; i < 3; i++) {
 	    float t = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f);
@@ -841,7 +798,6 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 
     return [this, speedMin, speedMax, offsetVal, scaleVal, forwardVal, timeScaleVal, phaseMinVal, phaseMaxVal, rightVal,
 	    speedOverride] (ParticleInstance& p) {
-	// Get direction parameters
 	glm::vec3 forward = forwardVal->getVec3 ();
 	glm::vec3 right = rightVal->getVec3 ();
 	// Y-flip for coordinate system conversion
@@ -867,10 +823,9 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	float phaseMin = phaseMinVal->getFloat ();
 	float phaseMax = phaseMaxVal->getFloat ();
 
-	// Sample noise at particle position + time-based offset.
-	// timescale shifts the noise field over time so particles spawned at different
-	// times get gradually changing directions (creates smooth evolving vapor stream).
-	// Position component provides spatial coherence for nearby particles.
+	// Sample noise at position + time offset: timescale shifts the field over time so
+	// particles spawned at different times drift differently (evolving vapor stream);
+	// the position term gives spatial coherence between nearby particles.
 	glm::vec3 noisePos = p.position * 0.1f;
 	noisePos += glm::vec3 (static_cast<float> (m_time) * timeScale);
 
@@ -878,7 +833,6 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	float phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax);
 	glm::vec3 samplePos = noisePos + glm::vec3 (phase, phase * 0.7f, phase * 1.3f);
 
-	// Sample curl noise for direction and normalize
 	glm::vec3 result = curlNoise (samplePos);
 	float len = glm::length (result);
 	if (len < 0.0001f) {
@@ -911,9 +865,8 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	    result = rot * result;
 	}
 
-	// For 2D/orthographic particles (flags & 4 == 0), project direction onto XY plane.
-	// curlNoise is 3D but z-drift is meaningless for 2D particles and causes
-	// rope segments to diverge in depth, breaking visual connectivity.
+	// 2D/orthographic particles (flags & 4 == 0): project onto XY. curlNoise is 3D but
+	// z-drift is meaningless here and makes rope segments diverge in depth.
 	if ((m_particle.flags & 4) == 0) {
 	    result.z = 0.0f;
 	    float len2d = glm::length (result);
@@ -922,7 +875,6 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	    }
 	}
 
-	// Apply speed and instance override
 	glm::vec3 finalVel = result * speed * speedOverride->getFloat ();
 
 	p.velocity += finalVel;
@@ -937,8 +889,8 @@ CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceArou
     DynamicValue* speedMaxValue = init.speedMax->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
-    // Sequence counter shared across all particles spawned with this initializer
-    // This creates the circular distribution pattern
+    // Sequence counter is shared (closure state) across all particles spawned by this
+    // initializer, giving each one a distinct angle around the circle
     int sequenceIndex = 0;
 
     return [this, controlPointValue, countValue, speedMinValue, speedMaxValue, sequenceIndex,
@@ -946,21 +898,16 @@ CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceArou
 	int controlPoint = static_cast<int> (controlPointValue->getFloat ());
 	int count = static_cast<int> (countValue->getFloat ());
 
-	// Calculate angle for this particle in the sequence (evenly distributed around circle)
 	float angle = (static_cast<float> (sequenceIndex) / static_cast<float> (count)) * glm::two_pi<float> ();
-	sequenceIndex = (sequenceIndex + 1) % count; // Wrap around after reaching count
+	sequenceIndex = (sequenceIndex + 1) % count;
 
-	// Get control point position to spawn around
 	glm::vec3 centerPos = glm::vec3 (0.0f);
 	if (controlPoint >= 0 && controlPoint < static_cast<int> (m_controlPoints.size ())) {
 	    centerPos = m_controlPoints[controlPoint].position;
 	}
 
-	// Set particle position in circular pattern around control point
-	// This creates the natural clustering seen in the original
 	p.position = centerPos;
 
-	// Set velocity based on angle and speed range
 	glm::vec3 speedMin = speedMinValue->getVec3 ();
 	glm::vec3 speedMax = speedMaxValue->getVec3 ();
 	glm::vec3 speed = WallpaperEngine::Maths::randomVec3 (m_rng, speedMin, speedMax);
@@ -968,13 +915,12 @@ CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceArou
 	// Flip Y before rotation to convert to centered space
 	speed.y = -speed.y;
 
-	// Rotate velocity based on sequence angle (creates outward radial pattern)
+	// Rotating by the sequence angle gives the outward radial/circular pattern
 	glm::mat3 rotationMatrix = glm::mat3 (
 	    std::cos (angle), -std::sin (angle), 0.0f, std::sin (angle), std::cos (angle), 0.0f, 0.0f, 0.0f, 1.0f
 	);
 	glm::vec3 rotatedSpeed = rotationMatrix * speed * speedOverride->getFloat ();
 
-	// Set velocity (speed override applied in movement operator)
 	p.velocity = rotatedSpeed;
     };
 }
@@ -1044,16 +990,13 @@ OperatorFunc CParticle::createMovementOperator (const MovementOperator& op) {
 		continue;
 	    }
 
-	    // Update position FIRST using current velocity
-	    // Velocity is already scaled by speed override
+	    // Integrate position from current velocity (already speed-scaled) before
+	    // updating velocity for next frame
 	    p.position += p.velocity * dt;
 
-	    // Then apply forces to modify velocity for NEXT frame
-	    // Apply gravity
 	    p.velocity += gravity * dt * speed;
 
-	    // Apply drag (velocity decay)
-	    // Clamp to prevent velocity reversal if drag*dt > 1.0
+	    // Drag decay, clamped so drag*dt > 1.0 can't reverse velocity
 	    float dragFactor = 1.0f - (drag * dt);
 	    if (dragFactor < 0.0f) {
 		dragFactor = 0.0f;
@@ -1082,15 +1025,11 @@ OperatorFunc CParticle::createAngularMovementOperator (const AngularMovementOper
 		continue;
 	    }
 
-	    // Update rotation using current angular velocity
 	    p.rotation += p.angularVelocity * dt * speed;
 
-	    // Apply force (angular acceleration)
 	    p.angularVelocity += force * dt * speed;
 
-	    // Apply drag (angular velocity decay)
-	    // Positive drag slows down, negative drag speeds up
-	    // Clamp to prevent velocity reversal if drag*dt > 1.0
+	    // Positive drag slows down, negative speeds up; clamped so drag*dt > 1.0 can't reverse it
 	    float dragFactor = 1.0f - (drag * dt);
 	    if (dragFactor < 0.0f) {
 		dragFactor = 0.0f;
@@ -1252,12 +1191,7 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
     DynamicValue* phaseMaxValue = op.phaseMax->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
-    // TODO: Audio processing support
-    // DynamicValue* audioModeValue = op.audioProcessingMode->value.get ();
-    // DynamicValue* audioBoundsValue = op.audioProcessingBounds->value.get ();
-    // DynamicValue* audioExponentValue = op.audioProcessingExponent->value.get ();
-    // DynamicValue* audioFreqStartValue = op.audioProcessingFrequencyStart->value.get ();
-    // DynamicValue* audioFreqEndValue = op.audioProcessingFrequencyEnd->value.get ();
+    // TODO: audio processing support (audioProcessingMode/Bounds/Exponent/FrequencyStart/FrequencyEnd)
 
     // Phase and speed are randomized once per operator instance, not per particle
     const float phase
@@ -1317,10 +1251,8 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
     DynamicValue* audioModeValue = op.audioProcessingMode->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
-    // Check if audio processing is enabled
     int audioMode = static_cast<int> (audioModeValue->getFloat ());
 
-    // Extract flag bits
     bool infiniteAxis = (flags & 1) != 0;
     bool maintainDistance = (flags & 2) != 0;
     bool ringShape = (flags & 4) != 0;
@@ -1331,10 +1263,9 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float, float dt
 	   ) {
-	// Audio modulation (when implemented, this will sample from audio context)
-	float audioAmplitude = 0.0f; // TODO: Sample from AudioContext when audio processing is implemented
+	float audioAmplitude = 0.0f; // TODO: sample from AudioContext once audio processing is implemented
 
-	// If audio mode is enabled but no audio, skip vortex entirely
+	// Audio mode enabled but no audio available yet - skip vortex entirely
 	if (audioMode > 0 && audioAmplitude == 0.0f) {
 	    return;
 	}
@@ -1351,13 +1282,11 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 	float ringPullDistance = ringPullDistanceValue->getFloat ();
 	float ringPullForce = ringPullForceValue->getFloat ();
 
-	// Apply audio modulation to speeds
 	if (audioMode > 0) {
 	    speedInner *= (1.0f + audioAmplitude);
 	    speedOuter *= (1.0f + audioAmplitude);
 	}
 
-	// Get vortex center from control point
 	glm::vec3 center = glm::vec3 (0.0f);
 	if (controlPoint >= 0 && controlPoint < static_cast<int> (controlPoints.size ())) {
 	    center = controlPoints[controlPoint].position + offset;
@@ -1365,11 +1294,10 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 	    center = offset;
 	}
 
-	// Normalize axis
 	if (glm::length (axis) > 0.0f) {
 	    axis = glm::normalize (axis);
 	} else {
-	    axis = glm::vec3 (0.0f, 0.0f, 1.0f); // Default to Z-axis
+	    axis = glm::vec3 (0.0f, 0.0f, 1.0f);
 	}
 
 	for (uint32_t i = 0; i < count; i++) {
@@ -1378,30 +1306,26 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 		continue;
 	    }
 
-	    // Calculate vector from center to particle
 	    glm::vec3 toParticle = p.position - center;
 
-	    // For infinite axis mode, project onto plane perpendicular to axis (cylinder shape)
-	    // Otherwise use full 3D distance (sphere shape)
+	    // infiniteAxis: project onto the plane perpendicular to axis (cylinder shape);
+	    // otherwise use full 3D distance (sphere shape)
 	    float axialDistance = 0.0f;
 	    glm::vec3 radialVector = toParticle;
 	    if (infiniteAxis) {
-		// Project out the axis component
 		axialDistance = glm::dot (toParticle, axis);
 		radialVector = toParticle - axis * axialDistance;
 	    }
 
 	    float distance = glm::length (radialVector);
 
-	    // Compute tangent direction (perpendicular to both axis and radial vector)
 	    glm::vec3 tangent = glm::cross (axis, radialVector);
 	    if (glm::length (tangent) > 0.001f) {
 		tangent = glm::normalize (tangent);
 	    } else {
-		continue; // Particle is on the axis
+		continue; // particle is on the axis
 	    }
 
-	    // Calculate spin speed and apply forces based on mode
 	    float speed = 0.0f;
 	    glm::vec3 radialForce = glm::vec3 (0.0f);
 
@@ -1421,7 +1345,6 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 		    // Outside ring but within pull distance - attract toward ring
 		    float pullT = (distance - ringOuter) / ringPullDistance;
 		    speed = speedOuter * (1.0f - pullT);
-		    // Pull toward ring
 		    if (distance > 0.001f) {
 			glm::vec3 towardRing = -glm::normalize (radialVector);
 			radialForce = towardRing * ringPullForce * pullT;
@@ -1444,13 +1367,10 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 		}
 	    }
 
-	    // Apply tangential velocity (spinning)
 	    p.velocity += tangent * speed * dt * speedOverride->getFloat ();
 
-	    // Apply radial force (ring pull)
 	    p.velocity += radialForce * dt * speedOverride->getFloat ();
 
-	    // Apply center force when maintain distance is enabled
 	    if (maintainDistance && distance > 0.001f) {
 		glm::vec3 towardCenter = -glm::normalize (radialVector);
 		p.velocity += towardCenter * centerForce * dt * speedOverride->getFloat ();
@@ -1470,35 +1390,27 @@ OperatorFunc CParticle::createControlPointAttractOperator (const ControlPointAtt
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float currentTime, float dt
 	   ) {
-	// Get dynamic values
 	glm::vec3 origin = originValue->getVec3 ();
 	float scale = scaleValue->getFloat ();
 	float threshold = thresholdValue->getFloat () / 2.0f;
 
-	// Get control point position
 	if (controlPoint < 0 || controlPoint >= static_cast<int> (controlPoints.size ())) {
 	    return;
 	}
 
 	glm::vec3 center = controlPoints[controlPoint].position + origin;
 
-	// Apply attraction force to all particles within threshold
 	for (uint32_t i = 0; i < count; i++) {
 	    auto& p = particles[i];
 	    if (!p.alive) {
 		continue;
 	    }
 
-	    // Calculate distance and direction to control point
 	    glm::vec3 toCenter = center - p.position;
 	    float distance = glm::length (toCenter);
 
-	    // Only apply force if within threshold
 	    if (distance > 0.001f && distance < threshold) {
-		// Normalize direction
 		glm::vec3 direction = toCenter / distance;
-
-		// Apply constant force in direction of control point
 		glm::vec3 forceVec = direction * scale * dt;
 		p.velocity += forceVec * speedOverride->getFloat ();
 	    }
@@ -1534,11 +1446,11 @@ OperatorFunc CParticle::createOscillateAlphaOperator (const OscillateAlphaOperat
 		    p.oscillateAlpha.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax);
 		    p.oscillateAlpha.phase
 			= WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
-		    p.oscillateAlpha.base = p.alpha; // Capture initial base
+		    p.oscillateAlpha.base = p.alpha;
 		    p.oscillateAlpha.initialized = true;
 		}
 
-		// Calculate oscillation: interpolate between scaleMin and scaleMax using cosine wave
+		// Cosine wave interpolating between scaleMin and scaleMax
 		float w = p.oscillateAlpha.frequency;
 		float t = p.age;
 		float cosVal = (std::cos (w * t + p.oscillateAlpha.phase) + 1.0f) * 0.5f;
@@ -1578,11 +1490,11 @@ OperatorFunc CParticle::createOscillateSizeOperator (const OscillateSizeOperator
 		    p.oscillateSize.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax);
 		    p.oscillateSize.phase
 			= WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
-		    p.oscillateSize.base = p.size; // Capture initial base
+		    p.oscillateSize.base = p.size;
 		    p.oscillateSize.initialized = true;
 		}
 
-		// Calculate oscillation: interpolate between scaleMin and scaleMax using cosine wave
+		// Cosine wave interpolating between scaleMin and scaleMax
 		float w = p.oscillateSize.frequency;
 		float t = p.age;
 		float cosVal = (std::cos (w * t + p.oscillateSize.phase) + 1.0f) * 0.5f;
@@ -1631,13 +1543,12 @@ OperatorFunc CParticle::createOscillatePositionOperator (const OscillatePosition
 		p.oscillatePosition.initialized = true;
 	    }
 
-	    // Calculate position delta for each axis
 	    float t = p.age;
 	    glm::vec3 delta (0.0f);
 
 	    for (int axis = 0; axis < 3; axis++) {
 		float w = 2.0f * glm::pi<float> () * p.oscillatePosition.frequency[axis] / (2.0f * glm::pi<float> ());
-		// Derivative of cos is -sin, multiply by dt for position change
+		// Derivative of cos is -sin; multiplied by dt for position change
 		float move
 		    = -p.oscillatePosition.scale[axis] * w * std::sin (w * t + p.oscillatePosition.phase[axis]) * dt;
 		// Apply mask as bias multiplier for this axis
@@ -1659,7 +1570,6 @@ void CParticle::setupPass () {
 
     const auto& firstPass = **m_particle.material->material->passes.begin ();
 
-    // Build override with particle-specific combos
     m_passOverride = std::make_unique<ImageEffectPassOverride> ();
     m_passOverride->combos["THICKFORMAT"] = 1;
     if (m_useRopeRenderer) {
@@ -1676,18 +1586,16 @@ void CParticle::setupPass () {
     // default "util/white" annotation, which would override it in setupRenderTexture()
     m_passBinds = { { 0, "previous" } };
 
-    // Check if material uses REFRACT combo
     auto refractIt = firstPass.combos.find ("REFRACT");
     m_hasRefract = refractIt != firstPass.combos.end () && refractIt->second != 0;
 
-    // Create the FBO provider for CPass
     m_passFBOProvider = std::make_shared<FBOProvider> (this);
 
-    // For REFRACT: create a copy FBO that shadows _rt_FullFrameBuffer.
-    // The REFRACT shader reads g_Texture3 (= _rt_FullFrameBuffer) while we render TO the scene FBO.
-    // Reading from the same FBO being rendered to is undefined behavior in OpenGL, causing
-    // black reads on NVIDIA. By placing a copy FBO with the same name in our FBOProvider,
-    // CPass resolves g_Texture3 to the copy instead. We blit the scene content before each render.
+    // REFRACT: create a copy FBO shadowing _rt_FullFrameBuffer. The shader reads g_Texture3
+    // (= _rt_FullFrameBuffer) while we render TO the scene FBO; reading and writing the same FBO
+    // is undefined behavior in OpenGL and causes black reads on NVIDIA. Placing a copy FBO under
+    // the same name in our FBOProvider makes CPass resolve g_Texture3 to the copy instead - we
+    // blit the scene content into it before each render.
     if (m_hasRefract) {
 	auto sceneFBO = getScene ().getFBO ();
 	float w = static_cast<float> (sceneFBO->getRealWidth ());
@@ -1697,10 +1605,8 @@ void CParticle::setupPass () {
 	);
     }
 
-    // Create CPass with the WP particle shader
     m_pass = new Effects::CPass (*this, m_passFBOProvider, firstPass, *m_passOverride, m_passBinds, std::nullopt);
 
-    // Set destination to scene FBO and input to particle texture
     m_pass->setDestination (getScene ().getFBO ());
     m_pass->setInput (getTexture ());
 
@@ -1710,7 +1616,6 @@ void CParticle::setupPass () {
     m_pass->setModelMatrix (&m_modelMatrix);
     m_pass->setViewProjectionMatrix (&m_viewProjectionMatrix);
 
-    // Create OpenGL buffers
     GLint prevVAO = 0;
     glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &prevVAO);
 
@@ -1911,12 +1816,10 @@ void CParticle::updateParticleViewProjection () {
     } else {
 	// Orthographic projection from scene camera
 	m_viewProjectionMatrix = getScene ().getCamera ().getProjection () * getScene ().getCamera ().getLookAt ();
-	// For 2D/orthographic scenes the camera eye is at (0,0,0). The shader's
-	// ComputeParticleTrailTangents uses cross(eyeDirection, velocity) to
-	// compute the trail ribbon width. With eye at z=0 and particles at z=0,
-	// eyeDirection is purely in XY — the cross product yields a Z-only vector
-	// that is invisible under orthographic projection. Place the eye at z=1000
-	// so the cross product produces a visible XY perpendicular direction.
+	// The shader's ComputeParticleTrailTangents computes trail ribbon width via
+	// cross(eyeDirection, velocity). With the ortho eye at (0,0,0) and particles at z=0,
+	// eyeDirection is purely XY, so the cross product is Z-only and invisible under
+	// orthographic projection. Placing the eye at z=1000 gives it a visible XY component.
 	m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f);
     }
 }
@@ -1929,19 +1832,18 @@ void CParticle::updateParticleRenderVars () {
 	float frameHeight = 1.0f / static_cast<float> (m_spritesheetRows);
 	float textureRatio = 1.0f;
 	if (const auto texture = getTexture ()) {
-	    // Use atlas dimensions (from resolution vec4) for textureRatio, NOT getRealWidth/Height
-	    // which returns per-frame dimensions for animated textures. The shader needs the
+	    // Use atlas dimensions (resolution vec4) rather than getRealWidth/Height, which
+	    // returns per-frame dimensions for animated textures - the shader needs the
 	    // per-frame pixel aspect ratio: (atlasH * frameHeight) / (atlasW * frameWidth).
 	    const glm::vec4* res = texture->getResolution ();
-	    float w = res->x; // atlas/GL texture width
-	    float h = res->y; // atlas/GL texture height
+	    float w = res->x;
+	    float h = res->y;
 	    if (w > 0.0f) {
 		textureRatio = (h * frameHeight) / (w * frameWidth);
 	    }
 	}
 	m_renderVar1 = glm::vec4 (frameWidth, frameHeight, static_cast<float> (m_spritesheetFrames), textureRatio);
     } else {
-	// No spritesheet - texture ratio is height/width
 	float textureRatio = 1.0f;
 	if (const auto texture = getTexture ()) {
 	    float w = static_cast<float> (texture->getRealWidth ());
@@ -1959,7 +1861,6 @@ void CParticle::renderSprites () {
 	return;
     }
 
-    // Count alive particles
     uint32_t aliveCount = 0;
     for (uint32_t i = 0; i < m_particleCount; i++) {
 	if (m_particles[i].alive) {
@@ -1989,11 +1890,10 @@ void CParticle::renderSprites () {
 	    continue;
 	}
 
-	// Compute the lifetime value for the WP shader's ComputeSpriteFrame.
-	// The shader computes: floor(frac(lifetime) * numFrames) to get current frame,
-	// and frac(lifetime * numFrames) for the blend factor between frames.
-	// We encode the CPU-computed p.frame (which accounts for sequenceMultiplier
-	// and animation mode) into the lifetime value the shader expects.
+	// Encode the CPU-computed frame (accounts for sequenceMultiplier and animation mode)
+	// into the lifetime value the WP shader's ComputeSpriteFrame expects: it derives the
+	// current frame via floor(frac(lifetime) * numFrames) and the inter-frame blend via
+	// frac(lifetime * numFrames).
 	float lifetime = p.getLifetimePos ();
 
 	if (m_spritesheetFrames > 0 && p.frame >= 0.0f) {
@@ -2001,9 +1901,6 @@ void CParticle::renderSprites () {
 		// Center within the frame to avoid floating-point edge cases
 		lifetime = (p.frame + 0.5f) / static_cast<float> (m_spritesheetFrames);
 	    } else {
-		// Encode frame index + fractional blend: shader reconstructs via
-		// floor(lifetime * numFrames) = current frame,
-		// frac(lifetime * numFrames) = blend toward next frame
 		lifetime = p.frame / static_cast<float> (m_spritesheetFrames);
 	    }
 	}
@@ -2035,14 +1932,12 @@ void CParticle::renderSprites () {
 	    vertexIndex++;
 	};
 
-	// 4 vertices for quad corners
 	uint32_t baseVertex = vertexIndex;
 	addVertex (0.0f, 1.0f); // 0: Bottom-left
 	addVertex (1.0f, 1.0f); // 1: Bottom-right
 	addVertex (1.0f, 0.0f); // 2: Top-right
 	addVertex (0.0f, 0.0f); // 3: Top-left
 
-	// 6 indices forming 2 triangles
 	m_indices[indexOffset++] = baseVertex + 0;
 	m_indices[indexOffset++] = baseVertex + 1;
 	m_indices[indexOffset++] = baseVertex + 2;
@@ -2063,7 +1958,6 @@ void CParticle::renderSprites () {
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
 #endif
 
-    // Upload vertex and index data
     glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
     glBufferData (
 	GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (vertexIndex * SPRITE_FLOATS_PER_VERTEX * sizeof (float)),
@@ -2076,12 +1970,10 @@ void CParticle::renderSprites () {
 	GL_DYNAMIC_DRAW
     );
 
-    // Update matrices and uniform data
     updateMatrices ();
 
-    // For REFRACT: blit current scene content into the copy FBO before rendering.
-    // This gives the shader a snapshot of what's behind the particles for refraction,
-    // without a feedback loop (rendering to scene FBO while reading from copy FBO).
+    // REFRACT: blit current scene content into the copy FBO first, giving the shader a
+    // snapshot of what's behind the particles without a read/write feedback loop
     if (m_hasRefract && m_refractFBO) {
 	auto sceneFBO = getScene ().getFBO ();
 	GLint w = static_cast<GLint> (sceneFBO->getRealWidth ());
@@ -2091,11 +1983,11 @@ void CParticle::renderSprites () {
 	glBlitFramebuffer (0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
     }
 
-    // The shader's ComputeParticleTrailTangents produces a right vector with a Z component
-    // (from cross(eyeDirection, velocity) where eyeDirection has XY offset from model transform).
-    // For 2D/ortho particles at z≈0, the ortho near plane sits at ndc.z=-1 — any Z offset from
-    // the right vector pushes vertices past the near plane, causing half the quad to be clipped.
-    // GL_DEPTH_CLAMP prevents near/far clipping by clamping depth instead.
+    // ComputeParticleTrailTangents produces a right vector with a Z component (from
+    // cross(eyeDirection, velocity), where eyeDirection has an XY offset from the model
+    // transform). For 2D/ortho particles at z=0, the ortho near plane sits at ndc.z=-1, so any
+    // Z offset pushes vertices past it and clips half the quad. GL_DEPTH_CLAMP avoids that by
+    // clamping depth instead of clipping.
     glEnable (GL_DEPTH_CLAMP);
 
     // CPass::render() handles: FBO binding, texture setup, uniforms, blending, draw call, cleanup
@@ -2113,13 +2005,12 @@ void CParticle::renderRope () {
 	return;
     }
 
-    // Array is already in spawn order (oldest at index 0) thanks to order-preserving
-    // compaction in update(). All particles in [0, m_particleCount) are alive.
+    // Already in spawn order (oldest at index 0) thanks to compaction in update();
+    // all particles in [0, m_particleCount) are alive.
     const uint32_t aliveCount = m_particleCount;
 
-    // Build vertex data with Catmull-Rom spline subdivision.
     // Each segment between consecutive particles is subdivided into m_ropeSubdivision
-    // sub-segments for smooth curves instead of harsh corners at particle positions.
+    // sub-segments via Catmull-Rom spline, for smooth curves instead of harsh corners.
     //
     // Rope vertex layout (26 floats per vertex, THICKFORMAT):
     // [0-3]   a_PositionVec4:   startPos.xyz, sizeStart
@@ -2133,7 +2024,6 @@ void CParticle::renderRope () {
     const uint32_t numSegments = aliveCount - 1;
     const int subdivision = std::max (1, m_ropeSubdivision);
 
-    // Catmull-Rom spline evaluation
     auto catmullRom = [] (const glm::vec3& p0, const glm::vec3& p1, const glm::vec3& p2, const glm::vec3& p3,
 			  float t) -> glm::vec3 {
 	float t2 = t * t, t3 = t2 * t;
@@ -2142,12 +2032,11 @@ void CParticle::renderRope () {
 	       + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3);
     };
 
-    // First pass: evaluate spline to get all interpolated points
+    // First pass: evaluate the spline to get all interpolated points (position, size, color)
     const uint32_t totalPoints = numSegments * subdivision + 1;
-    // Store position, size, color (rgba) per point = 3 + 1 + 4 = 8 floats
     this->m_splinePositions.resize (totalPoints);
     this->m_splineSizes.resize (totalPoints);
-    this->m_splineColors.resize (totalPoints); // rgba
+    this->m_splineColors.resize (totalPoints);
     auto& splinePositions = this->m_splinePositions;
     auto& splineSizes = this->m_splineSizes;
     auto& splineColors = this->m_splineColors;
@@ -2175,11 +2064,10 @@ void CParticle::renderRope () {
 	splineColors[totalPoints - 1] = glm::vec4 (pLast.color, pLast.alpha);
     }
 
-    // Second pass: build quads from consecutive spline points.
-    // The shader computes UV.v from trailPosition / (trailLength - 1), consuming
-    // 1/(trailLength-1) of UV space per quad. Express trailLength and trailPosition
-    // in sub-segment units so each sub-segment quad gets the correct UV slice.
-    // UV scale divides the effective length, making UVs exceed [0,1] → texture repeats.
+    // Second pass: build quads from consecutive spline points. The shader computes UV.v as
+    // trailPosition / (trailLength - 1), so trailLength/trailPosition are expressed in
+    // sub-segment units for the correct UV slice per quad. UV scale divides the effective
+    // length, pushing UVs past [0,1] so the texture repeats.
     uint32_t vertexIndex = 0;
     uint32_t indexOffset = 0;
     const uint32_t totalSubSegments = totalPoints - 1;
@@ -2282,7 +2170,6 @@ void CParticle::renderRope () {
 	addRopeVertex (1.0f, 1.0f); // right at end
 	addRopeVertex (0.0f, 1.0f); // left at end
 
-	// 2 triangles
 	m_indices[indexOffset++] = baseVertex + 0;
 	m_indices[indexOffset++] = baseVertex + 1;
 	m_indices[indexOffset++] = baseVertex + 2;
@@ -2303,7 +2190,6 @@ void CParticle::renderRope () {
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
 #endif
 
-    // Upload vertex and index data
     glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
     glBufferData (
 	GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (vertexIndex * ROPE_FLOATS_PER_VERTEX * sizeof (float)),
@@ -2316,10 +2202,9 @@ void CParticle::renderRope () {
 	GL_DYNAMIC_DRAW
     );
 
-    // Update matrices and uniform data
     updateMatrices ();
 
-    // For REFRACT: blit current scene content into the copy FBO before rendering
+    // REFRACT: blit current scene content into the copy FBO before rendering
     if (m_hasRefract && m_refractFBO) {
 	auto sceneFBO = getScene ().getFBO ();
 	GLint w = static_cast<GLint> (sceneFBO->getRealWidth ());

+ 4 - 37
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -22,27 +22,20 @@ namespace WallpaperEngine::Render::Objects {
 
 constexpr uint32_t DEFAULT_MAX_PARTICLES = 1000;
 
-/**
- * Runtime particle instance state
- */
 struct ParticleInstance {
-    // Position and movement
     glm::vec3 position { 0.0f };
     glm::vec3 velocity { 0.0f };
     glm::vec3 acceleration { 0.0f };
 
-    // Rotation
     glm::vec3 rotation { 0.0f };
     glm::vec3 angularVelocity { 0.0f };
     glm::vec3 angularAcceleration { 0.0f };
 
-    // Visual properties
     glm::vec3 color { 1.0f };
     float alpha { 1.0f };
     float size { 20.0f };
     float frame { 0.0f }; // Current animation frame
 
-    // Lifetime
     float lifetime { 1.0f }; // Total lifetime in seconds
     float age { 0.0f }; // Current age in seconds
 
@@ -73,15 +66,11 @@ struct ParticleInstance {
 
     bool alive { false };
 
-    // Get normalized lifetime position (0.0 to 1.0)
     float getLifetimePos () const { return lifetime > 0.0f ? (age / lifetime) : 1.0f; }
 
     bool isAlive () const { return alive && age < lifetime; }
 };
 
-/**
- * Control point runtime data
- */
 struct ControlPointData {
     glm::vec3 position { 0.0f };
     glm::vec3 offset { 0.0f };
@@ -89,19 +78,10 @@ struct ControlPointData {
     bool worldSpace { false };
 };
 
-/**
- * Particle emitter function
- */
 using EmitterFunc = std::function<void (std::vector<ParticleInstance>&, uint32_t&, float)>;
 
-/**
- * Particle initializer function
- */
 using InitializerFunc = std::function<void (ParticleInstance&)>;
 
-/**
- * Particle operator function
- */
 using OperatorFunc = std::function<
     void (std::vector<ParticleInstance>&, uint32_t, const std::vector<ControlPointData>&, float, float)>;
 
@@ -130,11 +110,9 @@ protected:
     void setupInitializers ();
     void setupOperators ();
 
-    // Emitter creators
     EmitterFunc createBoxEmitter (const ParticleEmitter& emitter);
     EmitterFunc createSphereEmitter (const ParticleEmitter& emitter);
 
-    // Initializer creators
     InitializerFunc createColorRandomInitializer (const ColorRandomInitializer& init);
     InitializerFunc createSizeRandomInitializer (const SizeRandomInitializer& init);
     InitializerFunc createAlphaRandomInitializer (const AlphaRandomInitializer& init);
@@ -146,7 +124,6 @@ protected:
     InitializerFunc
     createMapSequenceAroundControlPointInitializer (const MapSequenceAroundControlPointInitializer& init);
 
-    // Operator creators
     OperatorFunc createMovementOperator (const MovementOperator& op);
     OperatorFunc createAngularMovementOperator (const AngularMovementOperator& op);
     OperatorFunc createAlphaFadeOperator (const AlphaFadeOperator& op);
@@ -160,7 +137,6 @@ protected:
     OperatorFunc createOscillateSizeOperator (const OscillateSizeOperator& op);
     OperatorFunc createOscillatePositionOperator (const OscillatePositionOperator& op);
 
-    // Rendering
     void renderSprites ();
     void renderRope ();
     void setupPass ();
@@ -187,8 +163,7 @@ private:
     std::vector<float> m_vertices;
     std::vector<uint32_t> m_indices;
 
-    // Rope renderer scratch buffers - reused across frames (resize instead of reallocating) so
-    // renderRope() doesn't heap-allocate every frame the way a set of locals would.
+    // Reused across frames (resized, not reallocated) so renderRope() doesn't heap-allocate every frame
     std::vector<glm::vec3> m_splinePositions;
     std::vector<float> m_splineSizes;
     std::vector<glm::vec4> m_splineColors;
@@ -196,22 +171,19 @@ private:
 
     double m_time { 0.0 };
 
-    // Mouse-linked particle systems (cursor trails etc) run on unscaled real time so they always
-    // track the pointer 1:1, regardless of the global playback speed multiplier - see --speed.
+    // Mouse-linked systems run on unscaled real time so cursor trails track 1:1 regardless of --speed
     bool m_hasMouseControlPoint { false };
 
-    // CPass-based rendering
     Effects::CPass* m_pass { nullptr };
     std::unique_ptr<ImageEffectPassOverride> m_passOverride;
     std::shared_ptr<FBOProvider> m_passFBOProvider;
     TextureMap m_passBinds;
     GLsizei m_activeIndexCount { 0 };
 
-    // REFRACT support: copy of scene FBO to avoid read-write conflict
+    // REFRACT: copy of scene FBO to avoid read/write conflict
     bool m_hasRefract { false };
     std::shared_ptr<CFBO> m_refractFBO;
 
-    // OpenGL buffers
     GLuint m_vao { 0 };
     GLuint m_vbo { 0 };
     GLuint m_ebo { 0 };
@@ -232,17 +204,14 @@ private:
     glm::vec4 m_renderVar0 { 0.0f };
     glm::vec4 m_renderVar1 { 0.0f };
 
-    // Spritesheet animation data
     int m_spritesheetCols { 0 };
     int m_spritesheetRows { 0 };
     int m_spritesheetFrames { 0 };
     float m_spritesheetDuration { 1.0f };
 
-    // Material shader constants
     float m_overbright { 1.0f };
     float m_refractAmount { 0.05f }; // Default from shader annotation
 
-    // Renderer configuration
     bool m_useTrailRenderer { false };
     float m_trailLength { 0.05f };
     float m_trailMaxLength { 10.0f };
@@ -256,18 +225,16 @@ private:
     bool m_ropeUVSmoothing { true }; // rope only
     bool m_uniformLifetimes { false }; // true when lifetime min==max (enables UV smoothing)
 
-    // Per-vertex float counts for different renderer types
     static constexpr int SPRITE_FLOATS_PER_VERTEX = 17;
     static constexpr int ROPE_FLOATS_PER_VERTEX = 26;
 
-    // Transformed origin (screen space to centered space conversion)
+    // Screen space to centered space conversion
     glm::vec3 m_transformedOrigin { 0.0f };
 
     // Last known resolution for detecting changes
     float m_lastScreenWidth { 0.0f };
     float m_lastScreenHeight { 0.0f };
 
-    // Random number generator
     std::mt19937 m_rng;
 
     bool m_initialized { false };

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

@@ -28,7 +28,6 @@ void CRenderable::detectTexture () {
 void CRenderable::setup () {
     CObject::setup ();
 
-    // calculate full animation time (if any)
     this->m_animationTime = 0.0f;
 
     for (const auto& cur : this->getTexture ()->getFrames ()) {

+ 21 - 5
src/WallpaperEngine/Render/Objects/CSound.cpp

@@ -4,6 +4,8 @@
 
 #include "WallpaperEngine/FileSystem/Container.h"
 
+#include <algorithm>
+
 using namespace WallpaperEngine::Render::Objects;
 
 CSound::CSound (Wallpapers::CScene& scene, const Sound& sound) : CObject (scene, sound), m_sound (sound) {
@@ -13,7 +15,6 @@ CSound::CSound (Wallpapers::CScene& scene, const Sound& sound) : CObject (scene,
 }
 
 CSound::~CSound () {
-    // free all the sound buffers and streams
     for (const auto& stream : this->m_audioStreams) {
 	this->getScene ().getAudioContext ().removeStream (stream.first);
 	delete stream.second;
@@ -29,17 +30,32 @@ void CSound::load () {
 
 	stream->setRepeat (this->m_sound.playbackmode.has_value () && this->m_sound.playbackmode == "loop");
 
-	// add the stream to the context so it can be played
 	this->m_audioStreams.insert_or_assign (this->getScene ().getAudioContext ().addStream (stream), stream);
     }
 }
 
-void CSound::render () { }
+void CSound::render () { this->applyEffectiveVolume (); }
 
 void CSound::setVolumeOverride (std::optional<int> volume) {
-    const int driverValue = volume.has_value () ? *volume : -1;
+    this->m_screenVolumeOverride = volume;
+    this->applyEffectiveVolume ();
+}
+
+void CSound::applyEffectiveVolume () {
+    // The screen-level policy (mute/ambient-volume, set via setVolumeOverride from
+    // CScene::setAudioPolicy) and the wallpaper author's own per-object "volume" property (e.g.
+    // picking which of several alternate music tracks plays) are independent inputs that have to
+    // combine, not overwrite each other - otherwise picking a track would undo screen muting, or
+    // muting a screen would make track selection pointless.
+    const int base = this->m_screenVolumeOverride.value_or (
+	this->getContext ().getApp ().getContext ().state.audio.volume
+    );
+    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);
 
     for (const auto& entry : this->m_audioStreams) {
-	this->getScene ().getAudioContext ().setStreamVolume (entry.first, driverValue);
+	this->getScene ().getAudioContext ().setStreamVolume (entry.first, effective);
     }
 }

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

@@ -28,8 +28,12 @@ protected:
     void load ();
 
 private:
+    void applyEffectiveVolume ();
+
     std::map<int, Audio::AudioStream*> m_audioStreams = {};
 
     const Sound& m_sound;
+    /** Screen-level mute/ambient-volume policy from CScene::setAudioPolicy; nullopt = no override */
+    std::optional<int> m_screenVolumeOverride;
 };
 } // namespace WallpaperEngine::Render::Objects

+ 78 - 17
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -26,6 +26,64 @@ using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Objects::Effects;
 
 namespace {
+// Text arrives as UTF-8 (scene JSON, user input, scripted values), but FreeType's FT_Load_Char
+// takes one Unicode codepoint per call - decode UTF-8 into codepoints first, or multi-byte
+// characters (CJK, emoji, accented Latin) get fed one raw byte at a time and rendered as garbage.
+// Malformed sequences are skipped byte-by-byte rather than aborting the whole string.
+std::vector<char32_t> decodeUtf8 (const std::string& text) {
+    std::vector<char32_t> codepoints;
+    size_t i = 0;
+
+    while (i < text.size ()) {
+	const auto lead = static_cast<unsigned char> (text[i]);
+	size_t extraBytes;
+	char32_t codepoint;
+
+	if ((lead & 0x80) == 0x00) {
+	    codepoint = lead;
+	    extraBytes = 0;
+	} else if ((lead & 0xE0) == 0xC0) {
+	    codepoint = lead & 0x1F;
+	    extraBytes = 1;
+	} else if ((lead & 0xF0) == 0xE0) {
+	    codepoint = lead & 0x0F;
+	    extraBytes = 2;
+	} else if ((lead & 0xF8) == 0xF0) {
+	    codepoint = lead & 0x07;
+	    extraBytes = 3;
+	} else {
+	    // stray continuation byte or invalid lead byte - skip it and resync
+	    i++;
+	    continue;
+	}
+
+	if (i + extraBytes >= text.size ()) {
+	    // truncated multi-byte sequence at the end of the string
+	    break;
+	}
+
+	bool valid = true;
+	for (size_t k = 1; k <= extraBytes; k++) {
+	    const auto cont = static_cast<unsigned char> (text[i + k]);
+	    if ((cont & 0xC0) != 0x80) {
+		valid = false;
+		break;
+	    }
+	    codepoint = (codepoint << 6) | (cont & 0x3F);
+	}
+
+	if (!valid) {
+	    i++;
+	    continue;
+	}
+
+	codepoints.push_back (codepoint);
+	i += extraBytes + 1;
+    }
+
+    return codepoints;
+}
+
 // Fallback fonts, used only when the wallpaper's own font (loadEmbeddedFont) can't be loaded.
 const std::vector<std::string> kFontCandidates = {
     "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
@@ -35,8 +93,7 @@ const std::vector<std::string> kFontCandidates = {
 };
 
 // Wraps the FreeType-rasterized glyph coverage bitmap (single R8 channel) as a
-// TextureProvider so it can be fed into the normal CRenderable/CPass pipeline,
-// the same way AlbumTexture wraps a dynamically-loaded album cover.
+// TextureProvider so it can be fed into the normal CRenderable/CPass pipeline.
 class TextGlyphTexture final : public WallpaperEngine::Render::TextureProvider {
 public:
     TextGlyphTexture () {
@@ -93,7 +150,7 @@ private:
 
 // Base pass: tints the R8 glyph coverage texture with g_Color4, using WE's real "font" shader.
 // Normal (replace) blending so each frame fully overwrites the FBO's RGBA - CPass never clears
-// framebuffers between frames, and translucent blending would accumulate stale alpha over time.
+// framebuffers between frames, so translucent blending would accumulate stale alpha over time.
 MaterialUniquePtr buildFontMaterial () {
     auto pass = std::make_unique<MaterialPass> (MaterialPass {
 	.blending = BlendingMode_Normal,
@@ -295,14 +352,14 @@ 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.
+    // 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 already
-    // applied in render() - i.e. final size scales with scale^2, not scale^1.
+    // 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;
@@ -348,6 +405,11 @@ void CText::rebuildTextureFrom (const std::string& text) {
 	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]);
+    }
+
     struct LineMetrics {
 	int width = 0;
 	int ascent = 0;
@@ -363,7 +425,7 @@ void CText::rebuildTextureFrom (const std::string& text) {
 	int maxAscent = 0;
 	int maxDescent = 0;
 
-	for (unsigned char c : lines[i]) {
+	for (char32_t c : lineCodepoints[i]) {
 	    if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
 		continue;
 	    }
@@ -389,7 +451,7 @@ void CText::rebuildTextureFrom (const std::string& text) {
 	const int lineTop = static_cast<int> (i) * linePitch;
 	const int maxAscent = lineMetrics[i].ascent;
 
-	for (unsigned char c : lines[i]) {
+	for (char32_t c : lineCodepoints[i]) {
 	    if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
 		continue;
 	    }
@@ -646,11 +708,10 @@ void CText::render () {
     const float scene_w = getScene ().getCamera ().getWidth ();
     const float scene_h = getScene ().getCamera ().getHeight ();
 
-    // Match CImage's parallax handling (CImage.cpp:updateScreenSpacePosition), applied here in
-    // the same pre-scale, canvas-space units as origin - other objects at the same parallaxDepth
-    // (e.g. a background box behind this text) use this exact formula, so text needs it too to
-    // stay visually locked to them. Added directly to gl_origin (not appended after the model
-    // matrix) so the offset isn't inadvertently multiplied by this object's own "scale".
+    // Matches CImage's parallax handling (CImage.cpp:updateScreenSpacePosition) in the same
+    // pre-scale, canvas-space units as origin, so text stays visually locked to other objects at
+    // the same parallaxDepth. Added directly to gl_origin (not after the model matrix) so it
+    // isn't inadvertently multiplied by this object's own "scale".
     glm::vec2 parallaxOffset = { 0.0f, 0.0f };
     if (this->getScene ().getScene ().camera.parallax.enabled
 	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {

+ 84 - 72
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -6,6 +6,9 @@
 
 #include "WallpaperEngine/Data/Model/Effect.h"
 #include "WallpaperEngine/Data/Model/Material.h"
+#include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
 
 #include "WallpaperEngine/Render/CFBO.h"
 #include "WallpaperEngine/Render/Objects/CImage.h"
@@ -136,7 +139,6 @@ CPass::~CPass () {
     glDeleteVertexArrays (1, &m_vao);
     this->m_vao = GL_NONE;
 
-    // destroy shader programs
     if (!glIsProgram (this->m_programID)) {
 	return; // program already invalid or deleted
     }
@@ -168,7 +170,6 @@ std::shared_ptr<const TextureProvider> CPass::resolveTexture (
 	}
     }
 
-    // first check in the binds and replace it if necessary
     const auto it = this->m_binds.find (index);
 
     if (it == this->m_binds.end ()) {
@@ -180,10 +181,29 @@ std::shared_ptr<const TextureProvider> CPass::resolveTexture (
 	return this->m_previousInput ?: (previous ?: expected);
     }
 
-    // the bind actually has a name, search the FBO in the effect and return it
     return this->resolveFBO (it->second);
 }
 
+std::optional<std::string> CPass::resolveUserTextureName (const std::string& propertyName) const {
+    const auto& properties = this->m_renderable.getScene ().getScene ().project.properties;
+    const auto it = properties.find (propertyName);
+
+    if (it == properties.end ()) {
+	// not actually a property reference, treat it as a literal texture name like before
+	return propertyName;
+    }
+
+    const std::string& value = it->second->getString ();
+
+    if (value.empty ()) {
+	// the "scenetexture" property exists but the user hasn't imported an image for it -
+	// this is the normal state for most wallpapers that expose this as an optional slot
+	return std::nullopt;
+    }
+
+    return value;
+}
+
 std::shared_ptr<const CFBO> CPass::resolveFBO (const std::string& name) const {
     auto fbo = this->m_fboProvider->find (name);
 
@@ -195,7 +215,6 @@ std::shared_ptr<const CFBO> CPass::resolveFBO (const std::string& name) const {
 }
 
 void CPass::setupRenderFramebuffer () const {
-    // set the framebuffer we're drawing to
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_drawTo->getFramebuffer ());
 
     // Private per-object FBOs are never cleared elsewhere, so a blending pass would otherwise
@@ -209,10 +228,8 @@ void CPass::setupRenderFramebuffer () const {
 	glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
     }
 
-    // set proper viewport based on what we're drawing to
     glViewport (0, 0, this->m_drawTo->getRealWidth (), this->m_drawTo->getRealHeight ());
 
-    // set texture blending
     switch (this->getBlendingMode ()) {
 	case BlendingMode_Translucent:
 	    glEnable (GL_BLEND);
@@ -223,8 +240,12 @@ void CPass::setupRenderFramebuffer () const {
 	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE, GL_SRC_ALPHA, 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_ONE, GL_ZERO, GL_ONE, GL_ZERO);
+	    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
 	    break;
 	default:
 	    glDisable (GL_BLEND);
@@ -266,7 +287,6 @@ void CPass::setupRenderFramebuffer () const {
 }
 
 void CPass::setupRenderTexture () {
-    // use the shader we have registered
     glUseProgram (this->m_programID);
 
     auto texture0 = this->resolveTexture0 ();
@@ -374,7 +394,6 @@ void CPass::bindTextureUnit (int index, const std::shared_ptr<const TextureProvi
 
 void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const {
     for (auto [index, chain] : this->m_textures) {
-	// find the expected texture
 	auto expectedTexture = chain->texture;
 
 	do {
@@ -413,7 +432,6 @@ void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const
 }
 
 void CPass::setupRenderReferenceUniforms () {
-    // add reference uniforms
     for (const auto& value : this->m_referenceUniforms | std::views::values) {
 	switch (value->type) {
 	    case Double:
@@ -449,7 +467,6 @@ void CPass::setupRenderReferenceUniforms () {
 }
 
 void CPass::setupRenderUniforms () {
-    // add uniforms
     for (const auto& value : this->m_uniforms | std::views::values) {
 	switch (value->type) {
 	    case Double:
@@ -512,7 +529,6 @@ void CPass::renderGeometry () const {
 	return;
     }
 
-    // start actual rendering now
     glBindBuffer (GL_ARRAY_BUFFER, this->a_Position);
     glDrawArrays (GL_TRIANGLES, 0, 6);
 }
@@ -527,11 +543,9 @@ void CPass::cleanupRenderSetup () {
 	}
     }
 
-    // unbind all the used textures
     glActiveTexture (GL_TEXTURE0);
     glBindTexture (GL_TEXTURE_2D, 0);
 
-    // continue on the map from the second texture
     for (const auto& index : this->m_textures | std::views::keys) {
 	glActiveTexture (GL_TEXTURE0 + index);
 	glBindTexture (GL_TEXTURE_2D, 0);
@@ -539,7 +553,6 @@ void CPass::cleanupRenderSetup () {
 }
 
 void CPass::render () {
-    // set the VAO for now
     glBindVertexArray (this->m_vao);
 
     if (this->m_pass.shader == XRAY_EFFECT_SHADER) {
@@ -648,7 +661,6 @@ void CPass::setGeometryCallback (
 }
 
 GLuint CPass::compileShader (const char* shader, GLuint type) {
-    // reserve shaders in OpenGL
     const GLuint shaderID = glCreateShader (type);
 
     glShaderSource (shaderID, 1, &shader, nullptr);
@@ -657,27 +669,20 @@ GLuint CPass::compileShader (const char* shader, GLuint type) {
     GLint result = GL_FALSE;
     int infoLogLength = 0;
 
-    // ensure the vertex shader was correctly compiled
     glGetShaderiv (shaderID, GL_COMPILE_STATUS, &result);
     glGetShaderiv (shaderID, GL_INFO_LOG_LENGTH, &infoLogLength);
 
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
-	// ensure logBuffer ends with a \0
 	memset (logBuffer, 0, infoLogLength + 1);
-	// get information about the error
 	glGetShaderInfoLog (shaderID, infoLogLength, nullptr, logBuffer);
-	// throw an exception about the issue
 	std::stringstream buffer;
 	buffer << logBuffer << std::endl << "Compiled source code:" << std::endl << shader;
-	// free the buffer
 	delete[] logBuffer;
 
 	if (result == GL_FALSE) {
-	    // shader compilation failed completely, throw an exception
 	    sLog.exception (buffer.str ());
 	} else {
-	    // some warning was emitted, log the error and keep chuging along
 	    sLog.error (buffer.str ());
 	}
     }
@@ -686,14 +691,11 @@ GLuint CPass::compileShader (const char* shader, GLuint type) {
 }
 
 void CPass::setupShaders () {
-    // ensure the constants are defined
     const auto texture0 = this->m_renderable.getTexture ();
 
-    // copy the combos from the pass
     this->m_combos.insert (this->m_pass.combos.begin (), this->m_pass.combos.end ());
 
-    // TODO: THE VALUES ARE THE SAME AS THE ENUMERATION, SO MAYBE IT HAS TO BE SPECIFIED FOR THE TEXTURE 0 OF ALL
-    // ELEMENTS?
+    // TODO: the values match the enum - maybe this needs to apply to texture0 for all elements?
     if (texture0 != nullptr) {
 	if (texture0->getFormat () == TextureFormat_RG88) {
 	    this->m_combos.insert_or_assign ("TEX0FORMAT", 8);
@@ -702,15 +704,19 @@ void CPass::setupShaders () {
 	}
     }
 
-    // TODO: REVIEW THE SHADER TEXTURES HERE, THE ONES PASSED ON TO THE SHADER SHOULD NOT BE IN THE LIST
-    // TODO: USED TO BUILD THE TEXTURES LATER
+    // TODO: review the shader textures here; ones passed to the shader shouldn't be in this list
+    // (used later to build the textures)
     // use the combos copied from the pass so it includes the texture format
     const std::string& shaderName
 	= this->m_override.shaderOverride.has_value () ? this->m_override.shaderOverride.value () : this->m_pass.shader;
 
     TextureMap passTextures = this->m_pass.textures;
-    for (const auto& [index, texture] : this->m_pass.usertextures) {
-	passTextures.insert_or_assign (index, texture);
+    for (const auto& [index, propertyName] : this->m_pass.usertextures) {
+	// leave the default texture (if any) in place when the user hasn't provided an override,
+	// same rule applied when the actual texture chain gets built in setupTextureUniforms()
+	if (const auto resolved = this->resolveUserTextureName (propertyName); resolved.has_value ()) {
+	    passTextures.insert_or_assign (index, *resolved);
+	}
     }
 
     this->m_shader = new Render::Shaders::Shader (
@@ -729,16 +735,12 @@ void CPass::setupShaders () {
 	}
     }
 
-    // compile the shaders
     const GLuint vertexShaderID = compileShader (vertex.c_str (), GL_VERTEX_SHADER);
     const GLuint fragmentShaderID = compileShader (fragment.c_str (), GL_FRAGMENT_SHADER);
-    // create the final program
     this->m_programID = glCreateProgram ();
-    // link the shaders together
     glAttachShader (this->m_programID, vertexShaderID);
     glAttachShader (this->m_programID, fragmentShaderID);
     glLinkProgram (this->m_programID);
-    // check that the shader was properly linked
     GLint result = GL_FALSE;
     int infoLogLength = 0;
 
@@ -747,19 +749,13 @@ void CPass::setupShaders () {
 
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
-	// ensure logBuffer ends with a \0
 	memset (logBuffer, 0, infoLogLength + 1);
-	// get information about the error
 	glGetProgramInfoLog (this->m_programID, infoLogLength, nullptr, logBuffer);
-	// throw an exception about the issue
 	const std::string message = logBuffer;
-	// free the buffer
 	delete[] logBuffer;
 	if (result == GL_FALSE) {
-	    // shader compilation failed completely, throw an exception
 	    sLog.exception (message);
 	} else {
-	    // some warning was emitted, log the error and keep chuging along
 	    sLog.error (message);
 	}
     }
@@ -770,7 +766,7 @@ void CPass::setupShaders () {
     glObjectLabel (GL_SHADER, fragmentShaderID, -1, (shaderName + ".frag").c_str ());
 #endif /* DEBUG */
 
-    // after being liked shaders can be dettached and deleted
+    // once linked, the shaders themselves are no longer needed and can be detached/deleted
     glDetachShader (this->m_programID, vertexShaderID);
     glDetachShader (this->m_programID, fragmentShaderID);
 
@@ -779,12 +775,8 @@ void CPass::setupShaders () {
 
     // first setup the default values, these will be overwritten by future values
     this->setupShaderVariables ();
-    // setup uniforms
     this->setupUniforms ();
-    // setup attributes too
     this->setupAttributes ();
-    // get information from the program, like uniforms, etc
-    // support three textures for now
     this->g_Texture0Rotation = glGetUniformLocation (this->m_programID, "g_Texture0Rotation");
     this->g_Texture0Translation = glGetUniformLocation (this->m_programID, "g_Texture0Translation");
 }
@@ -795,10 +787,8 @@ void CPass::setupAttributes () {
 }
 
 void CPass::setupTextureUniforms () {
-    // first set default textures extracted from the shader
-    // vertex shader doesn't seem to have texture info
-    // but for now just set first vertex's textures
-    // and then try with fragment's and override any existing
+    // Vertex shaders don't carry texture info in practice, but check them first anyway;
+    // fragment textures are checked after and override/extend the chain.
     for (const auto& [index, textureName] : this->m_shader->getVertex ().getTextures ()) {
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
@@ -811,7 +801,10 @@ void CPass::setupTextureUniforms () {
 		.next = nullptr,
 	    });
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
+	    sLog.error (
+		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for fragment shader ", ex.what ()
+	    );
 	}
     }
 
@@ -829,7 +822,10 @@ void CPass::setupTextureUniforms () {
 
 	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
+	    sLog.error (
+		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for fragment shader ", ex.what ()
+	    );
 	}
     }
 
@@ -847,11 +843,23 @@ void CPass::setupTextureUniforms () {
 
 	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve texture ", textureName, " for pass ", ex.what ());
+	    sLog.error (
+		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for pass ", ex.what ()
+	    );
 	}
     }
 
-    for (const auto& [index, textureName] : this->m_pass.usertextures) {
+    for (const auto& [index, propertyName] : this->m_pass.usertextures) {
+	const auto resolvedName = this->resolveUserTextureName (propertyName);
+	if (!resolvedName.has_value ()) {
+	    // optional user-provided texture slot, nothing configured - keep whatever the
+	    // regular "textures" entry already set for this index (if any)
+	    continue;
+	}
+
+	const std::string& textureName = *resolvedName;
+
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
@@ -865,7 +873,10 @@ void CPass::setupTextureUniforms () {
 
 	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve user texture ", textureName, " for pass ", ex.what ());
+	    sLog.error (
+		"Cannot resolve user texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for pass ", ex.what ()
+	    );
 	}
     }
 
@@ -884,11 +895,21 @@ void CPass::setupTextureUniforms () {
 
 	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve texture ", textureName, " for override ", ex.what ());
+	    sLog.error (
+		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for override ", ex.what ()
+	    );
 	}
     }
 
-    for (const auto& [index, textureName] : this->m_override.usertextures) {
+    for (const auto& [index, propertyName] : this->m_override.usertextures) {
+	const auto resolvedName = this->resolveUserTextureName (propertyName);
+	if (!resolvedName.has_value ()) {
+	    continue;
+	}
+
+	const std::string& textureName = *resolvedName;
+
 	try {
 	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
 		? this->resolveFBO (textureName)
@@ -902,7 +923,10 @@ void CPass::setupTextureUniforms () {
 
 	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve user texture ", textureName, " for override ", ex.what ());
+	    sLog.error (
+		"Cannot resolve user texture '", textureName, "' (index=", index, ", object id=",
+		this->m_renderable.getId (), ") for override ", ex.what ()
+	    );
 	}
     }
 
@@ -918,9 +942,7 @@ void CPass::setupTextureUniforms () {
 	this->m_textures[index] = chain;
     }
 
-    // resolve the main texture
     std::shared_ptr<const TextureProvider> texture = this->resolveTexture (this->m_renderable.getTexture (), 0);
-    // register all the texture uniforms with correct values
     this->addUniform ("g_Texture0", 0);
     this->addUniform ("g_Texture1", 1);
     this->addUniform ("g_Texture2", 2);
@@ -1009,17 +1031,15 @@ template <typename T> void CPass::addUniform (const std::string& name, UniformTy
 	return;
     }
 
-    // free the uniform that's already registered if it's there already
+    // frees any previously registered value for this uniform name
     const auto it = this->m_uniforms.find (name);
 
     if (it != this->m_uniforms.end ()) {
 	delete it->second;
     }
 
-    // build a copy of the value and allocate it somewhere
     T* newValue = new T (value);
 
-    // uniform found, add it to the list
     this->m_uniforms.insert_or_assign (name, new UniformEntry (id, name, type, newValue, 1, true));
 }
 
@@ -1032,13 +1052,10 @@ template <typename T> void CPass::addUniform (const std::string& name, UniformTy
 	return;
     }
 
-    // free the uniform that's already registered if it's there already
-
     if (const auto it = this->m_uniforms.find (name); it != this->m_uniforms.end ()) {
 	delete it->second;
     }
 
-    // uniform found, add it to the list
     this->m_uniforms.insert_or_assign (name, new UniformEntry (id, name, type, value, count));
 }
 
@@ -1051,13 +1068,10 @@ template <typename T> void CPass::addUniform (const std::string& name, UniformTy
 	return;
     }
 
-    // free the uniform that's already registered if it's there already
-
     if (const auto it = this->m_uniforms.find (name); it != this->m_uniforms.end ()) {
 	delete it->second;
     }
 
-    // uniform found, add it to the list
     this->m_referenceUniforms.insert_or_assign (
 	name, new ReferenceUniformEntry (id, name, type, reinterpret_cast<const void**> (value))
     );
@@ -1107,10 +1121,8 @@ void CPass::setupShaderVariables () {
     }
 }
 
-// define some basic methods for the template
 void CPass::addUniform (ShaderVariable* value) {
-    // no need to re-implement this, call the version that takes a CDynamicValue as second parameter
-    // and that handles casting and everything
+    // delegates to the (ShaderVariable*, DynamicValue*) overload, which handles the casting
     this->addUniform (value, value);
 }
 

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

@@ -179,6 +179,17 @@ private:
 	std::shared_ptr<const TextureProvider> previous = nullptr
     );
 
+    /**
+     * "usertextures" entries in material/effect JSON are the *name* of a "scenetexture" general
+     * property, not a texture path - the actual texture to use is whatever the user configured that
+     * property to (which is commonly left unset). Resolves that indirection.
+     *
+     * @param propertyName The usertextures entry as it appears in the JSON
+     * @return The texture name to resolve, or nullopt if the slot is a known, intentionally unset
+     *         user-provided texture (no property value configured)
+     */
+    [[nodiscard]] std::optional<std::string> resolveUserTextureName (const std::string& propertyName) const;
+
     CRenderable& m_renderable;
     std::shared_ptr<const FBOProvider> m_fboProvider;
     const MaterialPass& m_pass;
@@ -202,9 +213,6 @@ private:
     float m_xrayFullReveal = 0.0f;
     bool m_xrayFullRevealPatched = false;
 
-    /**
-     * Contains the final map of textures to be used
-     */
     std::map<int, std::shared_ptr<TextureChainEntry>> m_textures = {};
 
     Render::Shaders::Shader* m_shader = nullptr;
@@ -216,7 +224,6 @@ private:
 
     GLuint m_programID;
 
-    // shader variables used temporary
     GLint g_Texture0Rotation;
     GLint g_Texture0Translation;
     GLuint a_TexCoord;

+ 0 - 3
src/WallpaperEngine/Render/RenderContext.cpp

@@ -23,9 +23,6 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
 #endif /* DEBUG */
 
-    // search the background in the viewport selection
-
-    // render the background
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
 	ref->second->render (
 	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize

+ 0 - 5
src/WallpaperEngine/Render/RenderContext.h

@@ -49,15 +49,10 @@ namespace Render {
 	[[nodiscard]] Media::MediaSource& getMediaSource () const;
 
     private:
-	/** Video driver in use */
 	Drivers::VideoDriver& m_driver;
-	/** Maps screen -> wallpaper list */
 	std::map<std::string, std::shared_ptr<CWallpaper>> m_wallpapers = {};
-	/** App that holds the render context */
 	WallpaperApplication& m_app;
-	/** Source for the media playback information */
 	Media::MediaSource& m_mediaSource;
-	/** Texture cache for the render */
 	std::unique_ptr<TextureCache> m_textureCache = nullptr;
     };
 } // namespace Render

+ 0 - 3
src/WallpaperEngine/Render/Shaders/GLSLContext.h

@@ -9,9 +9,6 @@
 namespace WallpaperEngine::Render::Shaders {
 class GLSLContext {
 public:
-    /**
-     * Types of shaders
-     */
     enum UnitType { UnitType_Vertex = 0, UnitType_Fragment = 1 };
 
     GLSLContext ();

+ 0 - 2
src/WallpaperEngine/Render/Shaders/Shader.cpp

@@ -2,7 +2,6 @@
 #include <string>
 #include <utility>
 
-// shader compiler
 #include <WallpaperEngine/Render/Shaders/Shader.h>
 #include <regex>
 
@@ -29,7 +28,6 @@ Shader::Shader (
 	textures, overrideTextures, combos, overrideCombos
     ),
     m_file (std::move (filename)), m_combos (combos), m_passTextures (textures) {
-    // link shaders between them
     this->m_vertex.linkToUnit (&this->m_fragment);
     this->m_fragment.linkToUnit (&this->m_vertex);
 }

+ 1 - 52
src/WallpaperEngine/Render/Shaders/Shader.h

@@ -26,75 +26,24 @@ public:
 	Variables::ShaderVariable* vertex;
 	Variables::ShaderVariable* fragment;
     };
-    /**
-     * Compiler constructor, loads the given shader file and prepares
-     * the pre-processing and compilation of the shader, adding
-     * required definitions if needed
-     *
-     * @param assetLocator The asset locator where to ask for assets for
-     * @param filename The file to load
-     * @param combos Settings for the shader
-     * @param overrideCombos List of override combos to use
-     * @param textures The list of available textures for the shader
-     * @param overrideTextures List of override textures to use
-     * @param constants Default values for shader variables
-     */
     Shader (
 	const AssetLocator& assetLocator, std::string filename, const ComboMap& combos, const ComboMap& overrideCombos,
 	const TextureMap& textures, const TextureMap& overrideTextures, const ShaderConstantMap& constants
     );
-    /**
-     * @return The vertex's shader coude for OpenGL to use
-     */
     const std::string& vertex ();
-    /**
-     * @return The fragment's shader code for OpenGL to use
-     */
     const std::string& fragment ();
-    /**
-     * @return The vertex shader unit
-     */
     [[nodiscard]] const ShaderUnit& getVertex () const;
-    /**
-     * @return The fragment shader unit
-     */
     [[nodiscard]] const ShaderUnit& getFragment () const;
-    /**
-     * @return The list of combos available for this shader after compilation
-     */
     [[nodiscard]] const std::map<std::string, int>& getCombos () const;
-    /**
-     * Searches for the given parameter in the two shader units and return whatever was found
-     *
-     * @param name
-     * @return
-     */
+    /** Searches both the vertex and fragment shader units for a parameter with this name */
     [[nodiscard]] ParameterSearchResult findParameter (const std::string& name) const;
 
 private:
-    /**
-     * The vertex shader unit used in this shader
-     */
     ShaderUnit m_vertex;
-    /**
-     * The fragment shader unit used in this shader
-     */
     ShaderUnit m_fragment;
-    /**
-     * The shader file this instance is loading
-     */
     std::string m_file;
-    /**
-     * The parameters the shader needs
-     */
     std::vector<Variables::ShaderVariable*> m_parameters = {};
-    /**
-     * The combos the shader should be generated with
-     */
     const ComboMap& m_combos;
-    /**
-     * The list of textures the pass knows about
-     */
     const TextureMap m_passTextures;
 };
 } // namespace WallpaperEngine::Render::Shaders

+ 75 - 85
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -44,7 +44,6 @@
 	  "#define saturate(x) (clamp(x, 0.0, 1.0))\n"                                                                 \
 	  "#define texSample2D texture\n"                                                                              \
 	  "#define texSample2DLod textureLod\n"                                                                        \
-	  "#define log10(x) (log2(x) * 0.301029995663981)\n"                                                           \
 	  "#define atan2 atan\n"                                                                                       \
 	  "#define fmod(x, y) ((x)-(y)*trunc((x)/(y)))\n"                                                              \
 	  "#define ddx dFdx\n"                                                                                         \
@@ -70,7 +69,6 @@ ShaderUnit::ShaderUnit (
     m_type (type), m_file (std::move (file)), m_content (std::move (content)), m_combos (combos),
     m_overrideCombos (overrideCombos), m_constants (constants), m_passTextures (passTextures),
     m_overrideTextures (overrideTextures), m_link (nullptr), m_assetLocator (assetLocator) {
-    // pre-process the shader so the units are clear
     this->preprocess ();
 }
 
@@ -81,22 +79,21 @@ void ShaderUnit::preprocess () {
     this->preprocessIncludes ();
     this->preprocessRequires ();
     this->preprocessVariables ();
+    this->preprocessBalanceConditionals ();
 
-    // replace gl_FragColor with the equivalent
     const std::string from = "gl_FragColor";
     const std::string to = "out_FragColor";
 
     size_t start_pos = 0;
     while ((start_pos = this->m_preprocessed.find (from, start_pos)) != std::string::npos) {
 	this->m_preprocessed.replace (start_pos, from.length (), to);
-	start_pos += to.length (); // Handles case where 'to' is a substring of 'from'
+	start_pos += to.length (); // avoids re-matching if 'to' is a substring of 'from'
     }
 }
 
 void ShaderUnit::preprocessVariables () {
     size_t start = 0, end = 0;
     while ((end = this->m_preprocessed.find ('\n', start)) != std::string::npos) {
-	// Extract a line from the string
 	std::string line = this->m_preprocessed.substr (start, end - start);
 	const size_t combo = line.find ("// [COMBO] ");
 	const size_t uniform = line.find ("uniform ");
@@ -107,18 +104,17 @@ void ShaderUnit::preprocessVariables () {
 	    this->parseComboConfiguration (line.substr (combo + strlen ("// [COMBO] ")), 0);
 	} else if (
 	    uniform != std::string::npos && comment != std::string::npos && semicolon != std::string::npos &&
-	    // this check ensures that the comment is after the semicolon (so it's not a commented-out line)
-	    // this needs further refining as it's not taking into account block comments
+	    // semicolon before comment means it's a trailing comment, not a commented-out line
+	    // (doesn't account for block comments)
 	    semicolon < comment
 	) {
-	    // uniforms with comments should never have a value assigned, use this fact to detect the required parts
+	    // uniforms with trailing comments never have a value assigned, which is what lets this find type/name
 	    const size_t last_space = line.find_last_of (' ', semicolon);
 
 	    if (last_space != std::string::npos) {
 		const size_t previous_space = line.find_last_of (' ', last_space - 1);
 
 		if (previous_space != std::string::npos) {
-		    // extract type and name
 		    std::string type = line.substr (previous_space + 1, last_space - previous_space - 1);
 		    std::string name = line.substr (last_space + 1, semicolon - last_space - 1);
 		    std::string json = line.substr (comment + 2);
@@ -128,23 +124,19 @@ void ShaderUnit::preprocessVariables () {
 	    }
 	}
 
-	// Move to the next line
 	start = end + 1;
     }
 }
 
 void ShaderUnit::preprocessIncludes () {
     size_t start = 0, end = 0;
-    // prepare the include content
     while ((start = this->m_preprocessed.find ("#include", end)) != std::string::npos) {
 	// TODO: CHECK FOR ERRORS HERE, MALFORMED INCLUDES WILL NOT BE PROPERLY HANDLED
 	const size_t quoteStart = this->m_preprocessed.find_first_of ('"', start) + 1;
 	const size_t quoteEnd = this->m_preprocessed.find_first_of ('"', quoteStart);
 	const std::string filename = this->m_preprocessed.substr (quoteStart, quoteEnd - quoteStart);
 
-	// some includes might not be present
-	// and that should not be treated as an error mainly because these could come from
-	// commented out content
+	// a missing include isn't necessarily an error - it may come from commented-out content
 	std::string content;
 
 	try {
@@ -161,19 +153,17 @@ void ShaderUnit::preprocessIncludes () {
 	    content += " but was not found\n";
 	}
 
-	// replace the first two letters with a comment so the filelength doesn't change
+	// comment out just the "#i" so the string length/offsets are unaffected
 	this->m_preprocessed = this->m_preprocessed.replace (start, 2, "//");
 
 	this->m_includes += content;
 
-	// go to the end of the line
 	end = start;
     }
 
-    // ensure the included files do not include other files
+    // resolve #include directives found inside already-included content too
     end = 0;
 
-    // then apply includes in-place
     while ((start = this->m_includes.find ("#include", end)) != std::string::npos) {
 	const size_t lineEnd = this->m_includes.find_first_of ('\n', start);
 	// TODO: CHECK FOR ERRORS HERE, MALFORMED INCLUDES WILL NOT BE PROPERLY HANDLED
@@ -181,9 +171,7 @@ void ShaderUnit::preprocessIncludes () {
 	const size_t quoteEnd = this->m_includes.find_first_of ('"', quoteStart);
 	const std::string filename = this->m_includes.substr (quoteStart, quoteEnd - quoteStart);
 
-	// some includes might not be present
-	// and that should not be treated as an error mainly because these could come from
-	// commented out content
+	// a missing include isn't necessarily an error - it may come from commented-out content
 	std::string content;
 
 	try {
@@ -200,18 +188,14 @@ void ShaderUnit::preprocessIncludes () {
 	    content += " but was not found\n";
 	}
 
-	// file contents ready, replace things
 	this->m_includes = this->m_includes.replace (start, lineEnd - start, content);
-
-	// go back to the beginning of the line to properly continue detecting things
 	end = start;
     }
 
-    // search for the main function and add the includes before that for now
+    // place the accumulated include contents right before the main function
     end = 0;
     bool includesAdded = false;
 
-    // finally, try to place the include contents before the main function
     while ((start = this->m_preprocessed.find (" main", end)) != std::string::npos) {
 	char value = this->m_preprocessed.at (start + 5);
 
@@ -221,7 +205,6 @@ void ShaderUnit::preprocessIncludes () {
 	    continue;
 	}
 
-	// main located, search for uniforms and find the latest one available
 	size_t lastAttribute = this->m_preprocessed.rfind ("attribute", start);
 	size_t lastVarying = this->m_preprocessed.rfind ("varying", start);
 	size_t lastUniform = this->m_preprocessed.rfind ("uniform", start);
@@ -247,18 +230,12 @@ void ShaderUnit::preprocessIncludes () {
 	    latest = this->m_preprocessed.rfind ('\n', start);
 	}
 
-	// update the function start to point to the end of the previous line
-	// as this will be used to determine the position of the includes
+	// start points at the end of the previous line, used below to place the includes
 	start = this->m_preprocessed.rfind ('\n', start);
 
-	// keeps track of the start and end of ifdefs to look for the right
-	// place to put the includes in
+	// tracks nested #if/#endif so the includes can be moved before the start of the enclosing chain
 	std::stack<size_t> ifdefStack;
 
-	// start looking for #if and #endif results and add to the stack so we find the start of the current chain of
-	// ifdefs and use that as point
-
-	// for this we'll use regex
 	const std::regex ifdef (R"((#if|#endif))");
 	std::smatch match;
 	size_t current = 0;
@@ -267,18 +244,14 @@ void ShaderUnit::preprocessIncludes () {
 	    std::regex_search (this->m_preprocessed.cbegin () + current, this->m_preprocessed.cend (), match, ifdef)) {
 	    current += match.position ();
 
-	    // if it's opening an #ifdef keep track of the start of the block
-	    // and that's it
 	    if (this->m_preprocessed.substr (current, 3) == "#if") {
-		// go to the next character so the regex doesn't match with the same thing again
-		ifdefStack.push (current++);
+		ifdefStack.push (current++); // advance past this match so regex_search doesn't rematch it
 		continue;
 	    }
 
-	    // go to the next character so the regex doesn't match with the same thing again
-	    current++;
+	    current++; // same reason: advance past this match
 
-	    // most likely a syntax error, but we'll ignore it for now...
+	    // an unmatched #endif is most likely a syntax error; ignored for now
 	    if (ifdefStack.empty ()) {
 		continue;
 	    }
@@ -287,19 +260,16 @@ void ShaderUnit::preprocessIncludes () {
 	    ifdefStack.pop ();
 
 	    if (latest > stackStart && latest <= current) {
-		// The insertion point is inside a conditional block.
-		// Move to BEFORE the #if so includes are available to all branches
-		// (e.g. genericropeparticle.vert has #if GS_ENABLED wrapping two main() functions).
+		// insertion point is inside a conditional block - move before the #if so includes are
+		// available to all branches (e.g. genericropeparticle.vert has #if GS_ENABLED wrapping two main()s)
 		size_t beforeIfdef = this->m_preprocessed.rfind ('\n', stackStart);
 		latest = (beforeIfdef != std::string::npos) ? beforeIfdef : 0;
 	    }
 	}
 
-	// no more matches, get the one that happens the earliest
 	// TODO: IS THIS GOOD ENOUGH? MAYBE WE SHOULD BE GETTING THE FIRST #IF BLOCK INSTEAD?
 	latest = std::min (latest, start);
 
-	// finally insert it there
 	this->m_preprocessed.insert (latest + 1, this->m_includes + '\n');
 	includesAdded = true;
 	break;
@@ -340,12 +310,10 @@ void ShaderUnit::preprocessRequires () {
 
 	std::string moduleCode = this->resolveRequireModule (moduleName);
 
-	// comment out the #require directive
 	this->m_preprocessed = this->m_preprocessed.replace (start, 2, "//");
 
 	if (!moduleCode.empty ()) {
-	    // insert the generated code directly into m_preprocessed at the #require location
-	    // (m_includes was already consumed by preprocessIncludes, so appending there would be lost)
+	    // inserted directly here, not appended to m_includes - that was already consumed by preprocessIncludes
 	    this->m_preprocessed.insert (start, moduleCode);
 	    end = start + moduleCode.length ();
 	} else {
@@ -364,9 +332,8 @@ std::string ShaderUnit::resolveRequireModule (const std::string& moduleName) con
 }
 
 std::string ShaderUnit::generateLightingV1 () const {
-    // PerformLighting_V1 is dynamically generated by Wallpaper Engine based on the scene's
-    // light sources. Since linux-wallpaperengine does not yet support light objects, we
-    // generate a stub that returns no dynamic light contribution.
+    // Wallpaper Engine generates this from the scene's light sources; since light objects
+    // aren't supported yet, stub it out with no dynamic light contribution.
     return "// begin of generated module LightingV1\n"
 	   "vec3 PerformLighting_V1(vec3 worldPos, vec3 albedo, vec3 normal, vec3 viewDir,\n"
 	   "    vec3 specularTint, vec3 baseReflectance, float roughness, float metallic)\n"
@@ -376,6 +343,37 @@ std::string ShaderUnit::generateLightingV1 () const {
 	   "// end of generated module LightingV1\n";
 }
 
+void ShaderUnit::preprocessBalanceConditionals () {
+    static const std::regex directive (R"((?:^|\n)[ \t]*#(ifndef|ifdef|if|endif)\b)");
+
+    int depth = 0;
+    std::vector<size_t> extraEndifs;
+
+    auto begin = std::sregex_iterator (this->m_preprocessed.cbegin (), this->m_preprocessed.cend (), directive);
+    auto end = std::sregex_iterator ();
+
+    for (auto it = begin; it != end; ++it) {
+	const std::string& keyword = (*it)[1].str ();
+
+	if (keyword == "endif") {
+	    if (depth == 0) {
+		// position of the '#' character for this directive
+		extraEndifs.push_back (it->position (1) - 1);
+	    } else {
+		depth--;
+	    }
+	} else {
+	    depth++;
+	}
+    }
+
+    // comment out the extra #endif directives, from the end so earlier offsets stay valid
+    for (auto it = extraEndifs.rbegin (); it != extraEndifs.rend (); ++it) {
+	sLog.out ("Found #endif with no matching #if in shader ", this->m_file, ", ignoring it");
+	this->m_preprocessed.replace (*it, 2, "//");
+    }
+}
+
 std::string ShaderUnit::applyLinkedVaryingCompatibility (std::string source) const {
     if (this->m_type != GLSLContext::UnitType_Vertex || this->m_link == nullptr) {
 	return source;
@@ -449,21 +447,16 @@ void ShaderUnit::parseComboConfiguration (const std::string& content, const int
 	return;
     }
     const auto combo = data.require<std::string> ("combo", "cannot parse combo information");
-    // ignore type as it seems to be used only on the editor
-    // const auto type = data.find ("type");
+    // "type" is ignored - appears to be editor-only metadata
     const auto defvalue = data.find ("default");
 
-    // check the combos
     const auto entry = this->m_combos.find (combo);
     const auto entryOverride = this->m_overrideCombos.find (combo);
 
-    // add the combo to the found list
     this->m_usedCombos.emplace (combo, true);
 
-    // if the combo was not found in the predefined values this means that the default value in the JSON data can be
-    // used so only define the ones that are not already defined
+    // not predefined anywhere -> fall back to the JSON's own default value
     if (entry == this->m_combos.end () && entryOverride == this->m_overrideCombos.end ()) {
-	// if no combo is defined just load the default settings
 	if (defvalue == data.end ()) {
 	    // TODO: PROPERLY SUPPORT EMPTY COMBOS
 	    this->m_discoveredCombos.emplace (combo, defaultValue);
@@ -491,10 +484,8 @@ void ShaderUnit::parseParameterConfiguration (
     }
     const auto material = data.optional ("material");
     const auto defvalue = data.optional ("default");
-    // auto range = data.find ("range");
     const auto combo = data.find ("combo");
 
-    // this is not a real parameter
     auto constant = this->m_constants.end ();
 
     if (material.has_value ()) {
@@ -529,40 +520,32 @@ void ShaderUnit::parseParameterConfiguration (
 	    parameter = new Variables::ShaderVariableInteger (defvalue->get<int> ());
 	}
     } else if (type == "sampler2D" || type == "sampler2DComparison") {
-	// samplers can have special requirements, check what sampler we're working with and create definitions
-	// if needed
 	const auto textureName = data.find ("default");
 	// TODO: CREATE TEXTURE WITH THE GIVEN COLOR
-	// extract the texture number from the name
 	const char value = name.at (std::string ("g_Texture").length ());
 	const auto requireany = data.find ("requireany");
 	const auto require = data.find ("require");
-	// now convert it to integer
 	// TODO: BETTER CONVERSION HERE
 	size_t index = value - '0';
 	// TODO: SUPPORT USER TEXTURES!!
 
 	if (combo != data.end ()) {
 	    // TODO: CLEANUP HOW THIS IS DETERMINED FIRST
-	    // if the texture exists (and is not null), add to the combo
 	    const auto textureSlotUsed
 		= this->m_passTextures.contains (index) || this->m_overrideTextures.contains (index);
 	    bool isRequired = false;
 	    int comboValue = 1;
 
 	    if (textureSlotUsed) {
-		// nothing extra to do, the texture exists, the combo must be set
-		// these tend to not have default value
+		// texture already exists, so the combo must be set; these tend to have no default value
 		isRequired = true;
 	    } else if (require != data.end ()) {
-		// this is required based on certain conditions
 		if (requireany != data.end () && requireany->get<bool> ()) {
-		    // any of the values set are valid, check for them
+		    // requireany: any one mismatching value makes this required (OR semantics)
 		    for (const auto& item : require->items ()) {
 			const std::string& macro = item.key ();
 			const auto it = this->m_combos.find (macro);
 
-			// if any of the values matched, this option is required
 			if (it == this->m_combos.end () || this->m_overrideCombos.contains (macro)
 			    || it->second != item.value ()) {
 			    isRequired = true;
@@ -572,12 +555,12 @@ void ShaderUnit::parseParameterConfiguration (
 		} else {
 		    isRequired = true;
 
-		    // all values must match for it to be required
+		    // require without requireany: every listed value must match (AND semantics)
 		    for (const auto& item : require->items ()) {
 			const std::string& macro = item.key ();
 			const auto it = this->m_combos.find (macro);
 
-			// these can not exist and that'd be fine, we just care about the values
+			// a missing macro is fine here, only the value comparison matters
 			if ((it != this->m_combos.end () || this->m_overrideCombos.contains (macro))
 			    && it->second == item.value ()) {
 			    isRequired = false;
@@ -591,12 +574,8 @@ void ShaderUnit::parseParameterConfiguration (
 		if (!defvalue.has_value ()) {
 		    isRequired = false;
 		} else {
-		    // is the combo registered already?
-		    // if not, add it with the default value
-		    // there's already a combo providing this value, so it doesn't need to be added
 		    if (this->m_combos.contains (*combo) || this->m_overrideCombos.contains (*combo)) {
 			isRequired = false;
-			// otherwise a default value must be used
 		    } else if (defvalue->is_string ()) {
 			comboValue = std::stoi (defvalue->get<std::string> ().c_str ());
 		    } else if (defvalue->is_number ()) {
@@ -611,18 +590,20 @@ void ShaderUnit::parseParameterConfiguration (
 	    }
 
 	    if (isRequired) {
-		// add the new combo to the list
 		this->m_discoveredCombos.emplace (*combo, comboValue);
-		// textures linked to combos need to be tracked too
 		this->m_usedCombos.emplace (*combo, true);
 	    }
 	}
 
-	if (textureName != data.end ()) {
+	// Some shaders (e.g. effects/refract.frag's normal map) declare `"default":""` on purpose -
+	// an explicitly empty default means "no texture unless the object's own effect config
+	// supplies one", not "a texture literally named the empty string". Registering it anyway
+	// sent every such object into a doomed asset lookup for a blank path on every use of that
+	// shader, whether or not the combo gating it was even active.
+	if (textureName != data.end () && !textureName->get<std::string> ().empty ()) {
 	    this->m_defaultTextures.emplace (index, *textureName);
 	}
 
-	// samplers are not saved, we can ignore them for now
 	return;
     } else {
 	sLog.error ("Unknown parameter type: ", type, " for ", name, " in shader ", this->m_file);
@@ -652,6 +633,17 @@ const std::string& ShaderUnit::compile () {
 
     this->m_final = SHADER_HEADER (this->m_file);
 
+    // GLSL has no builtin log10 (unlike HLSL), so some shaders provide their own under "#if GLSL"
+    // (which is always true here). Adding a blanket compatibility macro would get expanded right
+    // over such a shader's own function definition and mangle it, so only add it when the shader
+    // doesn't already define log10 itself.
+    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)) {
+	this->m_final += "#define log10(x) (log2(x) * 0.301029995663981)\n";
+    }
+
     if (this->m_type == GLSLContext::UnitType_Fragment) {
 	this->m_final += FRAGMENT_SHADER_DEFINES;
     } else {
@@ -670,7 +662,6 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    // now add all the combos to the source
     for (const auto& [name, value] : this->m_combos) {
 	std::string uppercase;
 	std::ranges::transform (name, std::back_inserter (uppercase), ::toupper);
@@ -713,11 +704,10 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    // this should be the rest of the shader
     this->m_final
 	+= this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed));
 
-    // the pass itself handles shader compilation, the unit doesn't have enough information for this step
+    // actual GLSL compilation happens in the pass, which has the context this unit doesn't
     return this->m_final;
 }
 

+ 11 - 110
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -17,9 +17,6 @@ using JSON = WallpaperEngine::Data::JSON::JSON;
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
 
-/**
- * Represents a whole shader unit
- */
 class ShaderUnit {
 public:
     ShaderUnit (
@@ -29,144 +26,54 @@ public:
     );
     ~ShaderUnit () = default;
 
-    /**
-     * Links this shader unit with another unit so they're treated as one
-     *
-     * @param unit
-     */
+    /** Links this shader unit with another unit so they're treated as one */
     void linkToUnit (const ShaderUnit* unit);
-    /**
-     * @return The shader unit linked to this unit (if any)
-     */
     [[nodiscard]] const ShaderUnit* getLinkedUnit () const;
 
-    /**
-     * @return The unit's source code already compiled and ready to be used by OpenGL
-     */
     [[nodiscard]] const std::string& compile ();
 
-    /**
-     * @return The parameters the shader unit has as input
-     */
     [[nodiscard]] const std::vector<Variables::ShaderVariable*>& getParameters () const;
-    /**
-     * @return The textures this shader unit requires
-     */
     [[nodiscard]] const TextureMap& getTextures () const;
-    /**
-     * @return The combos set for this shader unit by the configuration
-     */
     [[nodiscard]] const ComboMap& getCombos () const;
-    /**
-     * @return Other combos detected by this shader unit during the preprocess
-     */
+    /** Combos discovered during preprocessing that weren't in the configured combo list */
     [[nodiscard]] const ComboMap& getDiscoveredCombos () const;
 
 protected:
-    /**
-     * Extracts any and all possible shader combo configurations
-     * available in this shader unit, prepares includes
-     * and lays the ground for the actual code to be ready
-     */
     void preprocess ();
 
 private:
-    /**
-     * Parses the input shader looking for possible combo values that are required for it to properly work
-     */
     void preprocessVariables ();
-    /**
-     * Parses the input shader looking for include directives to extract the full list of included files
-     */
     void preprocessIncludes ();
-    /**
-     * Parses the input shader looking for require directives and resolves them into generated code
-     */
     void preprocessRequires ();
     /**
-     * Resolves a #require module name to generated GLSL code
-     *
-     * @param moduleName The module to resolve (e.g. "LightingV1")
-     * @return Generated GLSL code for the module, or empty string if unknown
+     * Some workshop shaders ship with unbalanced #if/#endif blocks (usually a stray extra #endif).
+     * Comments out any #endif without a matching #if/#ifdef/#ifndef so preprocessing doesn't fail outright.
      */
+    void preprocessBalanceConditionals ();
+    /** 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)
-     *
-     * @return GLSL code defining PerformLighting_V1
-     */
+    /** Generates the LightingV1 module stub (PerformLighting_V1 function) */
     [[nodiscard]] std::string generateLightingV1 () const;
-    /**
-     * Adjusts vertex varyings when a workshop shader declares a narrower vertex type than its fragment peer.
-     */
+    /** Adjusts vertex varyings when a workshop shader declares a narrower vertex type than its fragment peer. */
     [[nodiscard]] std::string applyLinkedVaryingCompatibility (std::string source) const;
-    /**
-     * Adjusts fragment shaders that use wide texture coordinates as vec2 values in Wallpaper Engine effects.
-     */
+    /** Adjusts fragment shaders that use wide texture coordinates as vec2 values in Wallpaper Engine effects. */
     [[nodiscard]] std::string applyFragmentTexCoordCompatibility (std::string source) const;
 
-    /**
-     * Parses a COMBO value to add the proper define to the code
-     *
-     * @param content The parameter configuration
-     * @param defaultValue
-     */
     void parseComboConfiguration (const std::string& content, int defaultValue = 0);
-    /**
-     * Parses a parameter extra metadata created by wallpaper engine
-     *
-     * @param type The type of variable to parse
-     * @param name The name of the variable in the shader (for actual variable declaration)
-     * @param content The parameter configuration
-     */
     void parseParameterConfiguration (const std::string& type, const std::string& name, const std::string& content);
-    /**
-     * The type of shder unit we have
-     */
+
     GLSLContext::UnitType m_type;
-    /**
-     * The filename of this shader unit
-     */
     std::string m_file;
-    /**
-     * Shader's original contents
-     */
     std::string m_content;
-    /**
-     * Includes content to be added on compilation
-     */
     std::string m_includes;
-    /**
-     * Shader's content after the preprocessing step
-     */
     std::string m_preprocessed;
-    /**
-     * Shader's code after the compilation of glslang and spirv
-     */
     std::string m_final;
-    /**
-     * The parameters the shader needs
-     */
     std::vector<Variables::ShaderVariable*> m_parameters = {};
-    /**
-     * Pre-defined values for the combos
-     */
     const ComboMap& m_combos;
-    /**
-     * Pre-defined overriden values for the combos
-     */
     const ComboMap& m_overrideCombos;
-    /**
-     * The combos discovered in the pre-processing step that were not in the combos list
-     */
+    /** Combos found during preprocessing that weren't already in m_combos */
     ComboMap m_discoveredCombos = {};
-    /**
-     * The combos used by this unit that should be added
-     */
     std::map<std::string, bool> m_usedCombos = {};
-    /**
-     * The constants defined for this unit
-     */
     const ShaderConstantMap& m_constants;
     /** The textures that are already applied to this shader */
     const TextureMap& m_passTextures;
@@ -174,13 +81,7 @@ private:
     const TextureMap& m_overrideTextures;
     /** The default textures to use when a texture is not applied in a given slot */
     TextureMap m_defaultTextures = {};
-    /**
-     * The shader unit this unit is linked to
-     */
     const ShaderUnit* m_link;
-    /**
-     * The container to source files from
-     */
     const AssetLocator& m_assetLocator;
 };
 }

+ 2 - 9
src/WallpaperEngine/Render/TextureCache.cpp

@@ -21,7 +21,6 @@ using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Assets;
 
 TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) {
-    // these textures are special cases, so make sure they're created only upon request
     this->m_currentThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
 #if !NDEBUG
@@ -34,21 +33,17 @@ TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (con
     glObjectLabel (GL_TEXTURE, this->m_previousThumbnail->getTextureID (0), -1, "$mediaPreviousThumbnail");
 #endif
 
-    // load the latest texture (if available)
     this->m_currentThumbnail->load ();
 
-    // add these to the cache and return the right one
     this->store ("$mediaThumbnail", this->m_currentThumbnail);
     this->store ("$mediaPreviousThumbnail", this->m_previousThumbnail);
 
     this->m_mediaCallback = this->getContext ().getMediaSource ().addAlbumArtListener (
 	[this] (const Media::MediaSource::MediaInfo& data) {
 	    if (this->m_currentThumbnail->isReady ()) {
-		// copy over pixel data and setup the new texture with the new data
 		this->m_previousThumbnail->copyContents (*this->m_currentThumbnail);
 	    }
 
-	    // load the next image
 	    this->m_currentThumbnail->load ();
 	}
     );
@@ -61,14 +56,12 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	return found->second;
     }
 
-    // search for the texture in all the different containers just in case
+    // 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);
 
-	    // Create metadata loader lambda that captures the assetLocator
-	    // so we need to construct the full path here
 	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
 		std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
 		return project->assetLocator->readString (fullPath);
@@ -89,7 +82,7 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	}
     }
 
-    // TODO: FILL IN WITH A CHECKERED PATTERN TEXTURE INSTEAD?
+    // TODO: fill in with a checkered pattern texture instead?
     throw AssetLoadException ("Cannot find file", filename, std::error_code ());
 }
 

+ 1 - 17
src/WallpaperEngine/Render/TextureCache.h

@@ -24,31 +24,15 @@ public:
     explicit TextureCache (RenderContext& context);
     ~TextureCache () override;
 
-    /**
-     * Checks if the given texture was already loaded and returns it
-     * If the texture was not loaded yet, it tries to load it from the container
-     *
-     * @param filename
-     * @return
-     */
+    /** Returns the cached texture for filename, loading it from the containers first if needed */
     std::shared_ptr<const TextureProvider> resolve (const std::string& filename);
 
-    /**
-     * Registers a texture in the cache
-     *
-     * @param name
-     * @param texture
-     */
     void store (const std::string& name, std::shared_ptr<const TextureProvider> texture);
 
 private:
-    /** The previous album thumbnail texture */
     std::shared_ptr<const AlbumTexture> m_previousThumbnail = nullptr;
-    /** The current album thumbnail texture */
     std::shared_ptr<const AlbumTexture> m_currentThumbnail = nullptr;
-    /** Cached textures */
     std::map<std::string, std::shared_ptr<const TextureProvider>> m_textureCache = {};
-    /** The callback to de-register media events */
     std::function<void ()> m_mediaCallback;
 };
 } // namespace WallpaperEngine::Render

+ 5 - 63
src/WallpaperEngine/Render/TextureProvider.h

@@ -19,87 +19,29 @@ class TextureProvider {
 public:
     virtual ~TextureProvider () = default;
 
-    /**
-     * @param imageIndex For animated textures, the frame to get the ID of
-     * @return The OpenGL texture to use when rendering
-     */
     [[nodiscard]] virtual GLuint getTextureID (uint32_t imageIndex) const = 0;
-    /**
-     * @param imageIndex For animated textures, the frame to get the ID of
-     * @return The texture's width
-     */
     [[nodiscard]] virtual uint32_t getTextureWidth (uint32_t imageIndex) const = 0;
-    /**
-     * @param imageIndex For animated textures, the frame to get the ID of
-     * @return The texture's height
-     */
     [[nodiscard]] virtual uint32_t getTextureHeight (uint32_t imageIndex) const = 0;
-    /**
-     * @return The textures real width
-     */
     [[nodiscard]] virtual uint32_t getRealWidth () const = 0;
-    /**
-     * @return The textures real height
-     */
     [[nodiscard]] virtual uint32_t getRealHeight () const = 0;
-    /**
-     * @return The texture's memory format
-     */
     [[nodiscard]] virtual TextureFormat getFormat () const = 0;
-    /**
-     * @return The texture's settings
-     */
     [[nodiscard]] virtual uint32_t getFlags () const = 0;
-    /**
-     * @return The list of frames this texture has
-     */
     [[nodiscard]] virtual const std::vector<FrameSharedPtr>& getFrames () const = 0;
-    /**
-     * @return The texture's resolution vector
-     */
     [[nodiscard]] virtual const glm::vec4* getResolution () const = 0;
-    /**
-     * @return If the texture is animated or not
-     */
     [[nodiscard]] virtual bool isAnimated () const = 0;
-    /**
-     * @return Number of columns in spritesheet grid (0 if not a spritesheet)
-     */
+    /** 0 if not a spritesheet */
     [[nodiscard]] virtual uint32_t getSpritesheetCols () const = 0;
-    /**
-     * @return Number of rows in spritesheet grid (0 if not a spritesheet)
-     */
+    /** 0 if not a spritesheet */
     [[nodiscard]] virtual uint32_t getSpritesheetRows () const = 0;
-    /**
-     * @return Total number of frames in spritesheet (0 if not a spritesheet)
-     */
+    /** 0 if not a spritesheet */
     [[nodiscard]] virtual uint32_t getSpritesheetFrames () const = 0;
-    /**
-     * @return Duration of spritesheet animation in seconds
-     */
     [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
-    /**
-     * @return If this texture is ready to be used or not
-     */
     virtual bool isReady () const = 0;
 
-    /**
-     * Increments the usage count of the texture
-     *
-     * Directly controls playback for video CTextures, only started when at least one thing is using it
-     * Initializes mpv if needed and starts playback
-     */
+    /** For video CTextures, playback only starts once usage count goes above zero (initializes mpv if needed) */
     virtual void incrementUsageCount () const = 0;
-    /**
-     * Decrements the usage count of the texture
-     *
-     * Directly controls playback for video CTextures, only stopped when nothing is using it
-     * De-initializes mpv if needed
-     */
+    /** For video CTextures, playback only stops once usage count reaches zero (de-initializes mpv if needed) */
     virtual void decrementUsageCount () const = 0;
-    /**
-     * Allows the texture contents to be updated (for example, for video textures)
-     */
     virtual void update () const = 0;
 };
 } // namespace WallpaperEngine::Render

+ 1 - 5
src/WallpaperEngine/Render/Utils/NoiseUtils.h

@@ -5,7 +5,6 @@
 
 namespace WallpaperEngine::Render::Utils {
 
-// Perlin noise permutation table
 static const unsigned char PERLIN_PERM[]
     = { 151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37,
 	240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177,
@@ -31,7 +30,6 @@ static const unsigned char PERLIN_PERM[]
 	214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114,
 	67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180 };
 
-// Perlin noise gradient function
 inline double perlinGrad (int hash, double x, double y, double z) {
     switch (hash & 0xF) {
 	case 0x0:
@@ -74,10 +72,8 @@ inline double perlinGrad (int hash, double x, double y, double z) {
 // Perlin noise ease curve (6t^5 - 15t^4 + 10t^3)
 inline double perlinEase (double t) { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }
 
-// Linear interpolation
 inline double lerpDouble (double t, double a, double b) { return a + t * (b - a); }
 
-// Perlin noise implementation
 inline double perlinNoise (double x, double y, double z) {
     int X = static_cast<int> (std::floor (x)) & 255;
     int Y = static_cast<int> (std::floor (y)) & 255;
@@ -117,7 +113,7 @@ inline double perlinNoise (double x, double y, double z) {
     );
 }
 
-// Perlin noise vec3 (3 independent noise samples with different offsets)
+// offset per axis so the 3 samples are decorrelated instead of identical
 inline glm::vec3 perlinNoiseVec3 (const glm::vec3& p) {
     return glm::vec3 (
 	static_cast<float> (perlinNoise (p.x, p.y, p.z)),

+ 18 - 68
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -29,43 +29,19 @@ CScene::CScene (
     // caller should check this, if not a std::bad_cast is good to throw
     auto scene = wallpaper.as<Scene> ();
 
-    // setup scripting engine
     this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
-    // setup the scene camera
     this->m_camera = std::make_unique<Camera> (*this, scene->camera);
 
     float width = scene->camera.projection.width;
     float height = scene->camera.projection.height;
 
-    // detect size if the orthogonal project is auto
+    // "auto" always matches the output resolution, same as real Wallpaper Engine. Used to be guessed
+    // from the bounding box of every Image object's origin+size, but one object with a declared
+    // "size" much bigger than what ends up on screen (e.g. an audio-bar visualizer sized for its
+    // theoretical max) was enough to inflate the canvas and shrink everything else into a corner.
     if (scene->camera.projection.isAuto) {
-	glm::vec2 maxExtent = { 0.0f, 0.0f };
-
-	for (const auto& object : scene->objects) {
-	    if (!object->is<Image> ()) {
-		continue;
-	    }
-
-	    const auto* image = object->as<Image> ();
-	    if (!image->origin || !image->origin->value) {
-		continue;
-	    }
-
-	    const glm::vec3 origin = image->origin->value->getVec3 ();
-	    const glm::vec2 halfSize = image->size / 2.0f;
-
-	    maxExtent.x = glm::max (maxExtent.x, glm::abs (origin.x) + halfSize.x);
-	    maxExtent.y = glm::max (maxExtent.y, glm::abs (origin.y) + halfSize.y);
-	}
-
-	if (maxExtent.x > 0.0f && maxExtent.y > 0.0f) {
-	    width = maxExtent.x * 2.0f;
-	    height = maxExtent.y * 2.0f;
-	} else {
-	    width = this->getContext ().getOutput ().getFullWidth ();
-	    height = this->getContext ().getOutput ().getFullHeight ();
-	    sLog.debug ("Auto projection: falling back to screen resolution ", width, "x", height);
-	}
+	width = this->getContext ().getOutput ().getFullWidth ();
+	height = this->getContext ().getOutput ().getFullHeight ();
     }
 
     this->m_parallaxDisplacement = { 0, 0 };
@@ -73,7 +49,7 @@ CScene::CScene (
     // TODO: CONVERSION
     this->m_camera->setOrthogonalProjection (width, height);
 
-    // setup framebuffers here as they're required for the scene setup
+    // needed before scene setup below, which creates FBOs
     this->setupFramebuffers ();
 
     const uint32_t sceneWidth = this->m_camera->getWidth ();
@@ -85,22 +61,20 @@ CScene::CScene (
     );
     this->alias ("_alias_lightCookie", "_rt_shadowAtlas");
 
-    // set clear color
     const glm::vec3 clearColor = scene->colors.clear->value->getVec3 ();
 
     glClearColor (clearColor.r, clearColor.g, clearColor.b, 1.0f);
 
-    // create all objects based off their dependencies
+    // createObject recurses into each object's dependencies/parent first
     for (const auto& object : scene->objects) {
 	this->createObject (*object);
     }
 
-    // copy over objects by render order
     for (const auto& object : scene->objects) {
 	this->addObjectToRenderOrder (*object);
     }
 
-    // create extra framebuffers for the bloom effect
+    // for the bloom effect below
     this->_rt_4FrameBuffer = this->create (
 	"_rt_4FrameBuffer", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0, { sceneWidth / 4, sceneHeight / 4 },
 	{ sceneWidth / 4, sceneHeight / 4 }
@@ -114,13 +88,9 @@ CScene::CScene (
 	{ sceneWidth / 8, sceneHeight / 8 }
     );
 
-    //
-    // Had to get a little creative with the effects to achieve the same bloom effect without any custom code
-    // this custom image loads some effect files from the virtual container to achieve the same bloom effect
-    // this approach requires of two extra draw calls due to the way the effect works in official WPE
-    // (it renders directly to the screen, whereas here we never do that from a scene)
-    //
-
+    // Bloom is achieved without any custom code by synthesizing a fake image object that loads
+    // effect files from the virtual container - this costs two extra draw calls versus official WPE,
+    // which renders bloom directly to the screen, something a scene here never does.
     const auto bloomOrigin = glm::vec3 { sceneWidth / 2, sceneHeight / 2, 0.0f };
     const auto bloomSize = glm::vec2 { sceneWidth, sceneHeight };
 
@@ -157,7 +127,6 @@ CScene::CScene (
 			) } } }
 	      ) } };
 
-    // create image for bloom passes
     if (scene->camera.bloom.enabled->value->getBool ()) {
 	this->m_bloomObjectData = ObjectParser::parse (bloom, scene->project);
 	this->m_bloomObject = this->createObject (*this->m_bloomObjectData);
@@ -181,12 +150,10 @@ CScene::~CScene () {
 Render::CObject* CScene::createObject (const Object& object) {
     Render::CObject* renderObject = nullptr;
 
-    // ensure the item is not loaded already
     if (const auto current = this->m_objects.find (object.id); current != this->m_objects.end ()) {
 	return current->second;
     }
 
-    // check dependencies too!
     for (const auto& cur : object.dependencies) {
 	// self-dependency is a possibility...
 	if (cur == object.id) {
@@ -201,7 +168,6 @@ Render::CObject* CScene::createObject (const Object& object) {
 	}
     }
 
-    // check if the item has any parent and also create it first
     if (object.parent.has_value ()) {
 	int parentId = object.parent.value ();
 
@@ -272,14 +238,12 @@ void CScene::addObjectToRenderOrder (const Object& object) {
 	return;
     }
 
-    // take into account any dependency first
     for (const auto& dep : object.dependencies) {
 	// self-dependency is possible
 	if (dep == object.id) {
 	    continue;
 	}
 
-	// add the dependency to the list if it's created
 	auto depIt = std::ranges::find_if (this->getScene ().objects, [&dep] (const auto& o) { return o->id == dep; });
 
 	if (depIt != this->getScene ().objects.end ()) {
@@ -289,7 +253,7 @@ void CScene::addObjectToRenderOrder (const Object& object) {
 	}
     }
 
-    // ensure we're added only once to the render list
+    // avoid adding the same object twice if several others depend on it
     const auto renderIt = std::ranges::find_if (this->m_objectsByRenderOrder, [&object] (const auto& o) {
 	return o->getId () == object.id;
     });
@@ -303,10 +267,8 @@ ScriptEngine& CScene::getScriptEngine () const { return *this->m_scriptEngine; }
 Camera& CScene::getCamera () const { return *this->m_camera; }
 
 void CScene::renderFrame (const glm::ivec4& viewport) {
-    // ensure the virtual mouse position is up to date
     this->updateMouse (viewport);
 
-    // update the parallax position if required
     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 ();
@@ -320,10 +282,9 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	    = glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
     }
 
-    // run a tick in the javascript logic
     this->getScriptEngine ().tick ();
 
-    // update main textures for images
+    // only image objects need their texture (e.g. video/gif frame) refreshed before drawing
     for (const auto& cur : this->m_objectsByRenderOrder) {
 	if (!cur->is<Objects::CImage> ()) {
 	    continue;
@@ -344,11 +305,8 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 #endif
     }
 
-    // bind the vertex array
     glBindVertexArray (this->m_vaoBuffer);
-    // use the scene's framebuffer by default
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
-    // ensure we render over the whole framebuffer
     glViewport (0, 0, this->m_sceneFBO->getRealWidth (), this->m_sceneFBO->getRealHeight ());
 
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
@@ -373,28 +331,21 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 }
 
 void CScene::updateMouse (const glm::ivec4& viewport) {
-    // update virtual mouse position first
     const glm::dvec2 position = this->getContext ().getInputContext ().getMouseInput ().position ();
 
-    // rollover the position to the last
     this->m_mousePositionLast = this->m_mousePosition;
 
-    // calculate the current position of the mouse in viewport space [0, 1]
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
-    // Normalize Y coordinate (OpenGL convention: 0=bottom, 1=top)
-    // Particle code expects this convention: 0=bottom results in negative Y (down), 1=top results in positive Y (up)
+    // OpenGL convention (0=bottom, 1=top) - particle code expects 0=bottom as negative Y (down)
     double normalizedMouseY = glm::clamp ((position.y - viewport.y) / viewport.w, 0.0, 1.0);
 
-    // Account for UV cropping when using fill/fit scaling modes
-    // The scene may be rendered larger than viewport and cropped via UVs
+    // fill/fit scaling modes can render the scene larger than the viewport and crop via UVs
     const auto uvs = this->getState ().getTextureUVs ();
 
-    // Map mouse position from viewport space to scene UV space
-    // UVs define what portion of the scene texture is visible
     this->m_mousePositionNormalized.x = uvs.ustart + mouseX * (uvs.uend - uvs.ustart);
     this->m_mousePositionNormalized.y = uvs.vstart + normalizedMouseY * (uvs.vend - uvs.vstart);
 
-    // Invert previous normalization of Y to match what the shader expects
+    // invert the Y normalization above to match what the shader expects
     double mouseY = 1.0 - normalizedMouseY;
 
     this->m_mousePosition.x = this->m_mousePositionNormalized.x;
@@ -413,8 +364,7 @@ float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
 
 float CScene::getFps () const {
     const float dt = g_Time - g_TimeLast;
-    // Guard against the first frame (where g_TimeLast is 0 so dt == g_Time)
-    // and division by zero on the very first call.
+    // avoids a division by zero / bogus fps on the first frame, where g_TimeLast is still 0
     if (dt <= 1e-6f) {
 	return 60.0f;
     }

+ 1 - 3
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -30,9 +30,7 @@ public:
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
 
-    // Time accessors used by dynamic text layers (CText + ScriptEngine).
-    // Read from the application-wide g_Time/g_TimeLast globals that other
-    // renderers already consume via extern (e.g. CParticle).
+    // Used by CText/ScriptEngine; read from the same g_Time/g_TimeLast globals CParticle consumes via extern.
     [[nodiscard]] float getTime () const;
     [[nodiscard]] float getDeltaTime () const;
     [[nodiscard]] float getFps () const;

+ 2 - 5
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -13,14 +13,12 @@ CVideo::CVideo (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
-    // setup framebuffers
     this->setupFramebuffers ();
 
     const std::filesystem::path videopath
 	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
 
-    // create a player with a small framebuffer
-    // this will be changed after mpv starts playback and sees the video resolution
+    // starts at a small framebuffer size; resized once mpv starts playback and reports the real resolution
     this->m_player = std::make_unique<GLPlayer> (
 	this->getContext (), this->CWallpaper::getWallpaperTexture (), videopath, 64, 64,
 	this->CWallpaper::getWallpaperFramebuffer ()
@@ -30,14 +28,13 @@ CVideo::CVideo (
     const auto& audioSettings = this->getContext ().getApp ().getContext ().settings.audio;
     this->m_player->setVolume (audioSettings.enabled ? audioSettings.volume * 100.0 / 128.0 : 0.0);
     this->m_player->setSpeed (this->getContext ().getApp ().getContext ().settings.render.playbackSpeed);
-    // make sure the video has at least one usage marked, this ensures the video plays
+    // needs at least one usage marked for the video to actually start playing
     this->m_player->incrementUsageCount ();
 }
 
 CVideo::~CVideo () { this->m_player->decrementUsageCount (); }
 
 void CVideo::renderFrame (const glm::ivec4& viewport) {
-    // ensure the video's audio follows audio detection rules and --audio-screen
     this->updateMuteState ();
 
     this->m_player->render ();

+ 0 - 4
src/WallpaperEngine/Render/Wallpapers/CWeb.cpp

@@ -146,16 +146,12 @@ void CWeb::renderFrame (const glm::ivec4& viewport) {
 	this->m_helperFailureLogged = true;
     }
 
-    // ensure the viewport matches the window size, and resize if needed
     if (viewport.z != this->getWidth () || viewport.w != this->getHeight ()) {
 	this->setSize (viewport.z, viewport.w);
     }
 
-    // ensure the virtual mouse position is up to date
     this->updateMouse (viewport);
-    // use the scene's framebuffer by default
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
-    // ensure we render over the whole framebuffer
     glViewport (0, 0, this->getWidth (), this->getHeight ());
 
     // Pull the latest frame the host process painted, if there's one we haven't uploaded yet.

+ 0 - 1
src/WallpaperEngine/Render/Wallpapers/CWeb.h

@@ -1,6 +1,5 @@
 #pragma once
 
-// Matrices manipulation for OpenGL
 #include <glm/ext.hpp>
 #include <glm/glm.hpp>
 

+ 46 - 7
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -1,8 +1,12 @@
 #include "ScriptableObjectAdapter.h"
 
+#include <cstring>
 #include <utility>
 
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
@@ -35,14 +39,36 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 
     ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
 
-    try {
-	// find the property inside, otherwise return undefined
-	auto& property = container->object.getProperty (name);
+    if (auto* property = container->object.tryGetProperty (name); property != nullptr) {
+	return container->adapter.getEngine ().dynamicToJs (*property);
+    }
+
+    // "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
+    // run their first update() from inside the base ScriptableObject constructor, before the
+    // derived render object has finished constructing - a dynamic_cast to it at that point would
+    // incorrectly report "not yet that type".
+    if (std::strcmp (name, "size") == 0) {
+	const auto& modelObject = container->object.getObject ();
+	const glm::vec2* size = nullptr;
+
+	if (modelObject.is<Image> ()) {
+	    size = &modelObject.as<Image> ()->size;
+	} else if (modelObject.is<Text> ()) {
+	    size = &modelObject.as<Text> ()->size;
+	}
 
-	return container->adapter.getEngine ().dynamicToJs (property);
-    } catch (const std::exception& e) {
-	return JS_UNDEFINED;
+	if (size != nullptr) {
+	    const DynamicValue sizeValue (*size);
+
+	    return container->adapter.getEngine ().getAdapters ().vec2->instantiate (
+		const_cast<DynamicValue&> (sizeValue), true
+	    );
+	}
     }
+
+    return JS_UNDEFINED;
 }
 
 int scriptableobject_property_set (
@@ -62,11 +88,24 @@ int scriptableobject_property_set (
 	return -1;
     }
 
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+
+    // Write through to the real property so `thisLayer.visible = ...` etc. actually takes
+    // effect. Properties not backed by a DynamicValue (e.g. "horizontalalign", plain model
+    // strings) fall through and return 0 rather than -1: since scripts run as strict-mode ES
+    // modules, returning -1 here would throw and abort the whole script over an unsupported
+    // property, so silently accepting the write is the safer default.
+    if (auto* property = container->object.tryGetProperty (name); property != nullptr) {
+	container->adapter.getEngine ().assignJsValue (val, *property);
+    }
+
     return 0;
 }
 
 ScriptableObjectAdapter::ScriptableObjectAdapter (ScriptEngine& engine, std::string name) :
-    ObjectAdapter (engine), m_exoticMethods (), m_name (std::move (name)) {
+    ObjectAdapter (engine),
+    m_exoticMethods ({ .get_property = scriptableobject_property_get, .set_property = scriptableobject_property_set }),
+    m_name (std::move (name)) {
     this->registerType (
 	{
 	    .class_name = m_name.c_str (),

+ 19 - 19
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -104,7 +104,6 @@ template <int components> auto vector_get (JSContext* ctx, JSValue source) -> de
     }
 
     if (tag == JS_TAG_OBJECT) {
-	// check components, extract x, y, z and w and create the appropriate vector
 	JSValue x = JS_GetPropertyStr (ctx, source, "x");
 	JSValue y = JS_GetPropertyStr (ctx, source, "y");
 	JSValue z = JS_GetPropertyStr (ctx, source, "z");
@@ -114,7 +113,6 @@ template <int components> auto vector_get (JSContext* ctx, JSValue source) -> de
 	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
 	}
 
-	// do not accept bigger vectors
 	if (components <= 2 && JS_IsNumber (z)) {
 	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
 	}
@@ -163,11 +161,9 @@ JSValue vector_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom,
 
     VEC_MAGIC_CHECK_EXCEPTION (container, components);
 
-    // An exotic get_property handler intercepts *every* property lookup on instances of this
-    // class - unlike a normal object, nothing here automatically falls through to the prototype
-    // chain. Vector methods (multiply, add, dot, cross, normalize, mix, ...) only exist on the
-    // class prototype, never as own properties of an instance, so anything other than x/y/z/w
-    // has to be looked up there manually below.
+    // Exotic get_property intercepts *every* lookup on this class, bypassing the prototype
+    // chain - so methods (multiply, add, dot, ...), which live only on the prototype, must be
+    // looked up manually below when the name isn't x/y/z/w.
     const char* name = JS_AtomToCString (ctx, atom);
 
     if (name != nullptr) {
@@ -296,7 +292,6 @@ template <int components> JSValue vector_copy (JSContext* ctx, JSValueConst this
 
     VEC_MAGIC_CHECK_EXCEPTION (container, components);
 
-    // create a new DynamicValue
     return container->adapter.instantiate (container->value, true);
 }
 
@@ -376,14 +371,10 @@ template JSValue vector_length<4> (JSContext* ctx, JSValueConst this_val, int ar
 
 template <int components>
 JSValue vector_constructor (JSContext* ctx, JSValueConst new_target, int argc, JSValueConst* argv, int magic) {
-    if (argc == 0) {
-	return JS_EXCEPTION;
-    }
-
     auto it = vectorAdapterInstances<components>.find (magic);
 
     if (it == vectorAdapterInstances<components>.end ()) {
-	return JS_EXCEPTION;
+	return JS_ThrowTypeError (ctx, "invalid vector%d constructor instance", components);
     }
 
     JSValue result = it->second.instantiate ();
@@ -392,7 +383,11 @@ JSValue vector_constructor (JSContext* ctx, JSValueConst new_target, int argc, J
 
     VEC_MAGIC_CHECK_EXCEPTION (container, components);
 
-    container->value.update (vector_get<components> (ctx, argv[0]), DynamicValue::UpdateSource::Initialization);
+    // `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) {
+	container->value.update (vector_get<components> (ctx, argv[0]), DynamicValue::UpdateSource::Initialization);
+    }
 
     return result;
 }
@@ -835,7 +830,6 @@ VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
 	}
     );
 
-    // build the prototype for the Vector and assign the required methods
     m_prototype = JS_NewObject (this->m_engine.getContext ());
 
     JS_DupValue (this->m_engine.getContext (), m_prototype);
@@ -930,15 +924,21 @@ VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
     );
 
     JS_SetClassProto (this->m_engine.getContext (), this->m_classId, m_prototype);
-    JS_FreeValue (this->m_engine.getContext (), ctor);
+
+    // JS_SetConstructor only wires up prototype<->constructor for instanceof; without exposing
+    // ctor as a global here, `new Vec3(...)` didn't exist, and scripts using it at module top
+    // level (not inside a function) would throw ReferenceError during module evaluation, killing
+    // that script before init()/update() ever ran.
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_engine.getGlobalThis (), this->m_name.c_str (), ctor, JS_PROP_ENUMERABLE
+    );
 }
 
 template <int components> VectorAdapter<components>::~VectorAdapter () {
     vectorAdapterInstances<components>.erase (this->m_instanceId);
 
-    // Runs after ScriptEngine has already freed the JS runtime/context (see ScriptEngine's
-    // destructor) - m_prototype and everything else tied to that context is already gone, so
-    // there's nothing left to explicitly release here.
+    // Runs after ScriptEngine has freed the JS runtime/context, so m_prototype and everything
+    // else tied to it is already gone - nothing left to release here.
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate (ScriptableObject& object) {

+ 0 - 1
src/WallpaperEngine/Scripting/ConsoleObject.cpp

@@ -54,7 +54,6 @@ ConsoleObject::ConsoleObject (ScriptEngine& engine, Render::Wallpapers::CScene&
 
     JS_DupValue (this->m_engine.getContext (), this->m_instance);
 
-    // set properties
     JS_SetOpaque (this->m_instance, this);
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "log",

+ 0 - 4
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -233,7 +233,6 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 
     JS_DupValue (this->m_engine.getContext (), this->m_instance);
 
-    // set properties
     JS_SetOpaque (this->m_instance, this);
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "frametime"),
@@ -295,7 +294,6 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 }
 
 EngineObject::~EngineObject () {
-    // clear all the timeouts and intervals
     for (const auto& [id, timeout] : this->m_timeouts) {
 	JS_FreeValue (this->m_engine.getContext (), timeout.callback);
     }
@@ -358,7 +356,6 @@ void EngineObject::clearTimeout (uint32_t id) {
 void EngineObject::tick () {
     const auto now = std::chrono::steady_clock::now ();
 
-    // check any interval and run them if needed
     for (auto& timeout : this->m_intervals | std::views::values) {
 	if (timeout.next > now) {
 	    continue;
@@ -371,7 +368,6 @@ void EngineObject::tick () {
 
     std::vector<uint32_t> removeTimeouts;
 
-    // check any timeout and run them if needed
     for (auto& [id, timeout] : this->m_timeouts) {
 	if (timeout.next > now) {
 	    continue;

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott