浏览代码

fixed CI pipeline, small effect fixes

UwU 1 天之前
父节点
当前提交
b62d9a126d

+ 52 - 16
.github/workflows/cmake.yml

@@ -118,7 +118,7 @@ jobs:
         run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{matrix.build_type}} -DDISABLE_KDE_FEATURES=${{matrix.disable_kde}} -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache
 
       - name: Build
-        run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}} --verbose
+        run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}} --parallel --verbose
 
       - name: Perform CodeQL Analysis
         if: ${{ matrix.build_type == 'Debug' }}
@@ -126,20 +126,7 @@ jobs:
 
       - name: Package
         if: ${{ matrix.build_type == 'Release' }}
-        run: |
-          name=linux-wallpaperengine-${{ matrix.variant }}-ubuntu-24.04-x86_64
-          cmake --install build --prefix "$RUNNER_TEMP/install"
-          # the install also carries the dependencies' own tools, headers and static libs, keep only what runs
-          pkg="$RUNNER_TEMP/$name"
-          mkdir -p "$pkg/lib"
-          cd "$RUNNER_TEMP/install"
-          cp -a linux-wallpaperengine liblinux-wallpaperengine-lib.so libcef.so libEGL.so libGLESv2.so \
-            libvk_swiftshader.so vk_swiftshader_icd.json chrome-sandbox icudtl.dat v8_context_snapshot.bin \
-            *.pak locales "$pkg/"
-          cp -a lib/libkissfft-float.so* "$pkg/lib/"
-          # CEF ships libcef.so with debug symbols, 1.4 GB instead of about 220 MB
-          strip --strip-unneeded "$pkg/linux-wallpaperengine" "$pkg/liblinux-wallpaperengine-lib.so" "$pkg/libcef.so"
-          tar -czf "$RUNNER_TEMP/$name.tar.gz" -C "$RUNNER_TEMP" "$name"
+        run: tools/package_release.sh build linux-wallpaperengine-${{ matrix.variant }}-ubuntu-24.04-x86_64 "$RUNNER_TEMP"
 
       - uses: actions/upload-artifact@v4
         if: ${{ matrix.build_type == 'Release' }}
@@ -148,8 +135,57 @@ jobs:
           path: ${{ runner.temp }}/linux-wallpaperengine-${{ matrix.variant }}-ubuntu-24.04-x86_64.tar.gz
           if-no-files-found: error
 
+  distro-build:
+    needs: tests
+    strategy:
+      fail-fast: false
+      matrix:
+        distro: [fedora-44, arch]
+        variant: [kde, generic]
+        include:
+          - distro: fedora-44
+            image: fedora:44
+            install: >-
+              dnf -y install git gcc gcc-c++ make cmake pkgconf-pkg-config binutils tar gzip
+              libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel libXxf86vm-devel
+              mesa-libGL-devel mesa-libEGL-devel glew-devel freeglut-devel sdl2-compat-devel lz4-devel
+              zlib-ng-compat-devel ffmpeg-free-devel glm-devel glfw-devel mpv-devel pulseaudio-libs-devel
+              freetype-devel harfbuzz-devel dbus-devel wayland-devel wayland-protocols-devel fftw-devel gmp-devel
+          - distro: arch
+            image: archlinux:latest
+            install: >-
+              pacman -Syu --noconfirm --needed git base-devel cmake glew freeglut sdl2-compat lz4 zlib
+              ffmpeg glm glfw mpv libpulse freetype2 harfbuzz dbus wayland wayland-protocols mesa
+              libxrandr libxinerama libxcursor libxi libxxf86vm fftw gmp
+    runs-on: ubuntu-24.04
+    container: ${{ matrix.image }}
+
+    steps:
+      # git has to be there before checkout, otherwise it falls back to a tarball without submodules
+      - name: Install dependencies
+        run: ${{ matrix.install }}
+
+      - uses: actions/checkout@v4
+        with:
+          submodules: true
+
+      - name: Configure CMake
+        run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DDISABLE_KDE_FEATURES=${{ matrix.variant == 'generic' && 'ON' || 'OFF' }}
+
+      - name: Build
+        run: cmake --build build --parallel
+
+      - name: Package
+        run: tools/package_release.sh build linux-wallpaperengine-${{ matrix.variant }}-${{ matrix.distro }}-x86_64 "$RUNNER_TEMP"
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: linux-wallpaperengine-${{ matrix.variant }}-${{ matrix.distro }}
+          path: ${{ runner.temp }}/linux-wallpaperengine-${{ matrix.variant }}-${{ matrix.distro }}-x86_64.tar.gz
+          if-no-files-found: error
+
   release:
-    needs: build
+    needs: [build, distro-build]
     if: startsWith(github.ref, 'refs/tags/v')
     runs-on: ubuntu-latest
     permissions:

+ 2 - 0
CMakeLists.txt

@@ -493,6 +493,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CRenderable.h
     src/WallpaperEngine/Render/Objects/CImage.h
     src/WallpaperEngine/Render/Objects/CImage.cpp
+    src/WallpaperEngine/Render/Objects/PuppetPhysics.h
+    src/WallpaperEngine/Render/Objects/PuppetPhysics.cpp
     src/WallpaperEngine/Render/Objects/CSound.h
     src/WallpaperEngine/Render/Objects/CSound.cpp
     src/WallpaperEngine/Render/Objects/CParticle.h

+ 16 - 4
README.md

@@ -26,32 +26,44 @@ No GUI. This is a command-line tool driven entirely by flags - see Usage below.
 - MPV
 - PulseAudio
 - FFTW3
+- FreeType, HarfBuzz
 - D-Bus (for the KDE integration, see Build)
 
 ### Ubuntu 22.04
 ```bash
 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 libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev libdbus-1-dev
+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 libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev libharfbuzz-dev libdbus-1-dev
 ```
 
 ### Ubuntu 24.04
 ```bash
 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 libdbus-1-dev
+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 libharfbuzz-dev libdbus-1-dev
 ```
 
 ### 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 dbus-devel
+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 freetype-devel harfbuzz-devel gmp-devel dbus-devel
 ```
 
 ### 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 libdbus-devel
+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 libfreetype-devel libharfbuzz-devel libdbus-devel
 ```
 
+## Prebuilt downloads
+
+Tagged releases on the [releases page](https://github.com/WallpaperEngineLover/linux-wallpaperengine-kde/releases) have ready-to-run builds for Ubuntu 24.04, Fedora 44 and Arch, each with (`kde`) and without (`generic`) the KDE Plasma integration. They use the distro's own ffmpeg, mpv, GLFW and so on, so pick the one for your distro and install the runtime packages from the list above (the non `-dev`/`-devel` ones). Arch builds follow the rolling release and can break after big library updates, rebuild from source then.
+
+```bash
+tar xzf linux-wallpaperengine-kde-fedora-44-x86_64.tar.gz
+./linux-wallpaperengine-kde-fedora-44-x86_64/linux-wallpaperengine --help
+```
+
+The folder can live anywhere, the binary finds its libraries next to itself.
+
 ## Build
 
 ```bash

+ 1 - 1
src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp

@@ -20,7 +20,7 @@ using namespace WallpaperEngine::Render::Drivers;
 void CustomGLFWErrorHandler (int errorCode, const char* reason) { sLog.error ("GLFW error ", errorCode, ": ", reason); }
 
 GLFWOpenGLDriver::GLFWOpenGLDriver (const char* windowTitle, ApplicationContext& context, WallpaperApplication& app) :
-    VideoDriver (app, m_mouseInput), m_context (context), m_mouseInput (*this) {
+    VideoDriver (app, m_mouseInput), m_mouseInput (*this) {
     glfwSetErrorCallback (CustomGLFWErrorHandler);
 
     if (glfwInit () == GLFW_FALSE) {

+ 0 - 1
src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.h

@@ -37,7 +37,6 @@ public:
     GLFWwindow* getWindow () const;
 
 private:
-    ApplicationContext& m_context;
     Input::Drivers::GLFWMouseInput m_mouseInput;
     Output::Output* m_output = nullptr;
     GLFWwindow* m_window = nullptr;

+ 10 - 1
src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.cpp

@@ -5,6 +5,7 @@
 
 #include <EGL/eglext.h>
 #include <algorithm>
+#include <cstdio>
 #include <cstdlib>
 #include <cstring>
 #include <filesystem>
@@ -70,8 +71,16 @@ EGLDisplay openDisplay () {
 }
 } // namespace
 
+HeadlessOpenGLDriver::FixedMouse::FixedMouse (const HeadlessOpenGLDriver& driver) : m_driver (driver) {
+    const char* cursor = std::getenv ("LWE_HEADLESS_CURSOR");
+
+    if (cursor != nullptr && std::sscanf (cursor, "%lf,%lf", &m_fraction.x, &m_fraction.y) != 2) {
+	m_fraction = glm::dvec2 (0.5);
+    }
+}
+
 HeadlessOpenGLDriver::HeadlessOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
-    VideoDriver (app, m_mouseInput), m_context (context), m_mouseInput (*this) {
+    VideoDriver (app, m_mouseInput), m_mouseInput (*this) {
     this->m_display = openDisplay ();
 
     const EGLint configAttributes[] = { EGL_SURFACE_TYPE,

+ 5 - 4
src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.h

@@ -38,21 +38,22 @@ public:
 private:
     class FixedMouse final : public Input::MouseInput {
     public:
-	explicit FixedMouse (const HeadlessOpenGLDriver& driver) : m_driver (driver) { }
+	explicit FixedMouse (const HeadlessOpenGLDriver& driver);
 
 	void update () override { }
-	// the middle of the output, where the pointer of a fresh Xvfb display sits too
-	[[nodiscard]] glm::dvec2 position () const override { return glm::dvec2 (m_driver.m_size) / 2.0; }
+	// the middle of the output (where the pointer of a fresh Xvfb display sits too), or LWE_HEADLESS_CURSOR=x,y
+	// as fractions of the output
+	[[nodiscard]] glm::dvec2 position () const override { return glm::dvec2 (m_driver.m_size) * m_fraction; }
 	[[nodiscard]] Input::MouseClickStatus leftClick () const override { return Input::Released; }
 	[[nodiscard]] Input::MouseClickStatus rightClick () const override { return Input::Released; }
 
     private:
 	const HeadlessOpenGLDriver& m_driver;
+	glm::dvec2 m_fraction = glm::dvec2 (0.5);
     };
 
     void createSurface ();
 
-    ApplicationContext& m_context;
     FixedMouse m_mouseInput;
     Output::Output* m_output = nullptr;
     EGLDisplay m_display = EGL_NO_DISPLAY;

+ 242 - 98
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -5,6 +5,7 @@
 #include <algorithm>
 #include <array>
 #include <cstdio>
+#include <cstdlib>
 #include <cstring>
 #include <iterator>
 #include <limits>
@@ -35,6 +36,7 @@ using namespace WallpaperEngine::Data::Builders;
 using namespace WallpaperEngine::Data::Utils;
 
 extern float g_Time;
+extern float g_TimeLast;
 
 namespace {
 glm::vec2 rotateVec2 (const glm::vec2& value, float angle) {
@@ -328,12 +330,14 @@ struct PuppetBoneSet {
     // puppets with no attachment points, or MDAT (attachment points) otherwise - the caller has to
     // check which one it actually is.
     size_t nextSectionOffset = 0;
+    // MDLS v2+ records after the bones, every MDLA clip carries one track per entry of each
+    uint32_t extraCount = 0;
+    uint32_t constraintCount = 0;
 };
 
-// Parses the MDLS section's first bone array (local bind-pose transforms + parent hierarchy). The
-// second bone array isn't decoded: its per-bone "name" slot turns out to hold physics/jiggle constraint
-// parameters (angle limits, stiffness, a target position) rather than anything about mesh skinning, and
-// inverse-bind matrices can be derived from the first array alone by walking the parent chain anyway.
+// Parses the MDLS bones (local bind-pose transforms, parent hierarchy and the per-bone physics JSON) and the
+// counts of the records after them that size the MDLA tracks. Inverse-bind matrices are derived from the bind
+// pose by walking the parent chain; the file's own copy is skipped.
 PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
     reader.base ().seekg (static_cast<std::streamoff> (mdlsOffset), std::ios::beg);
 
@@ -389,11 +393,52 @@ PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
 	}
 
 	// trailing per-bone string, jiggle/physics JSON for some rigs
+	const std::string physics = reader.nextNullTerminatedString ();
+
+	result.bones.push_back (
+	    PuppetBone { .parent = parent, .bindLocal = bindLocal, .physics = PuppetBonePhysics::parse (physics) }
+	);
+    }
+
+    // the rest of MDLS as 2.8.42 reads it (sub_140261880), only the two counts matter here
+    const int version = std::atoi (header + 4);
+
+    if (version < 2 || result.bones.size () != boneCount) {
+	return result;
+    }
+
+    uint16_t extraCount = 0;
+    reader.next (reinterpret_cast<char*> (&extraCount), sizeof (extraCount));
+
+    for (uint16_t i = 0; i < extraCount; i++) {
 	(void)reader.nextNullTerminatedString ();
+	reader.base ().seekg (sizeof (uint32_t) * 2 + sizeof (float) * 16, std::ios::cur);
+    }
 
-	result.bones.push_back (PuppetBone { .parent = parent, .bindLocal = bindLocal });
+    if (reader.next () != 0) {
+	reader.base ().seekg (static_cast<std::streamoff> ((boneCount + extraCount) * sizeof (float) * 16), std::ios::cur);
     }
 
+    const uint32_t constraintCount = reader.nextUInt32 ();
+
+    for (uint32_t i = 0; i < constraintCount && reader.base ().good (); i++) {
+	reader.base ().seekg (sizeof (uint32_t) * 3, std::ios::cur);
+	const uint32_t flags = version >= 4 ? reader.nextUInt32 () : 0;
+
+	if (flags & 2) {
+	    reader.base ().seekg (sizeof (uint32_t) + sizeof (float), std::ios::cur);
+	}
+    }
+
+    if (!reader.base ().good () || static_cast<size_t> (reader.base ().tellg ()) > nextSectionOffset) {
+	reader.base ().clear ();
+	sLog.error ("Puppet MDLS records after the bones don't fit the section, animation tracks may not line up");
+	return result;
+    }
+
+    result.extraCount = extraCount;
+    result.constraintCount = constraintCount;
+
     return result;
 }
 
@@ -512,79 +557,24 @@ parsePuppetAttachmentPoints (const BinaryReader& reader, size_t mdatOffset, uint
     return result;
 }
 
-// Looks ahead from searchStart for the next byte offset that looks like a valid clip header, to
-// resynchronize past the still-undecoded per-clip trailer when a MDLA section holds more than one clip.
-std::optional<size_t> findNextPuppetClipHeader (
-    const std::vector<char>& data, size_t searchStart, size_t searchLimit, uint32_t expectedBoneCount
-) {
-    const auto readCString = [&data] (size_t& cursor) -> std::optional<std::string> {
-	const size_t start = cursor;
-	while (cursor < data.size () && data[cursor] != 0) {
-	    const auto byte = static_cast<unsigned char> (data[cursor]);
-	    if (byte < 0x20 || byte > 0x7e || cursor - start > 64) {
-		return std::nullopt;
-	    }
-	    cursor++;
-	}
-	if (cursor >= data.size () || cursor == start) {
-	    return std::nullopt;
-	}
-	std::string value (data.data () + start, cursor - start);
-	cursor++;
-	return value;
-    };
-
-    for (size_t offset = searchStart; offset < searchLimit; offset++) {
-	size_t cursor = offset;
-	if (!readCString (cursor).has_value () || !readCString (cursor).has_value ()) {
-	    continue;
-	}
-	if (cursor + 16 > data.size ()) {
-	    continue;
-	}
-
-	float fps;
-	uint32_t frameCount;
-	uint32_t flag;
-	uint32_t boneCount;
-	std::memcpy (&fps, data.data () + cursor, sizeof (fps));
-	std::memcpy (&frameCount, data.data () + cursor + 4, sizeof (frameCount));
-	std::memcpy (&flag, data.data () + cursor + 8, sizeof (flag));
-	std::memcpy (&boneCount, data.data () + cursor + 12, sizeof (boneCount));
-
-	if (fps >= 1.0f && fps <= 240.0f && frameCount >= 1 && frameCount <= 100000 && flag == 0
-	    && boneCount == expectedBoneCount) {
-	    return offset;
-	}
-    }
-
-    return std::nullopt;
-}
-
-// Parses every baked animation clip out of the MDLA section (see docs/rendering/MDL_FILES.md).
+// Parses every baked animation clip out of the MDLA section (see docs/rendering/MDL_FILES.md), laid out like
+// 2.8.42 reads it (sub_140261880). Only the bone tracks are used, everything after them is skipped by its size.
 std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
-    const std::vector<char>& data, const BinaryReader& reader, size_t mdlaOffset, uint32_t expectedBoneCount
+    const BinaryReader& reader, size_t mdlaOffset, uint32_t expectedBoneCount, const PuppetBoneSet& rig,
+    uint32_t meshCount
 ) {
     reader.base ().seekg (static_cast<std::streamoff> (mdlaOffset), std::ios::beg);
 
     char header[9];
     reader.next (header, sizeof (header));
+    const int version = std::atoi (header + 4);
 
-    (void)reader.nextUInt32 (); // total content size, unused
+    const uint32_t sectionEnd = reader.nextUInt32 ();
     const uint32_t clipCount = reader.nextUInt32 ();
-    (void)reader.nextUInt32 (); // ambiguous animation id when there's more than one clip; matched by name instead
-    (void)reader.nextUInt32 (); // unused, always 0 in every sample seen
 
-    // this whole section is only trustworthy insofar as the MDLS "mdlaOffset" field that got us here
-    // actually landed on a real MDLA layout for this MDLV sub-format - it's only been confirmed against
-    // MDLV0023 samples so far. A clip count this large can only be a garbage read, not a real file.
     constexpr uint32_t maxPlausibleClipCount = 64;
     if (clipCount > maxPlausibleClipCount) {
-	sLog.error (
-	    "Puppet animation clip count (", clipCount,
-	    ") looks implausible, assuming this puppet's MDLA layout wasn't "
-	    "recognized and skipping animation entirely"
-	);
+	sLog.error ("Puppet animation clip count (", clipCount, ") looks implausible, skipping animation entirely");
 	return {};
     }
 
@@ -593,38 +583,51 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 
     for (uint32_t clipIndex = 0; clipIndex < clipCount; clipIndex++) {
 	PuppetAnimationClip clip;
+	reader.base ().seekg (sizeof (uint64_t), std::ios::cur); // animation id
 	clip.name = reader.nextNullTerminatedString ();
 	clip.mode = reader.nextNullTerminatedString ();
 	clip.fps = reader.nextFloat ();
 	clip.frameCount = reader.nextUInt32 ();
-	(void)reader.nextUInt32 (); // unused, always 0 in every sample seen
+	const uint32_t flags = reader.nextUInt32 ();
 	const uint32_t boneCount = reader.nextUInt32 ();
 
 	constexpr uint32_t maxPlausibleFrameCount = 100000;
 	if (clip.frameCount > maxPlausibleFrameCount || boneCount != expectedBoneCount) {
 	    sLog.error (
 		"Puppet animation clip ", clipIndex, " has an implausible frame/bone count (frames=", clip.frameCount,
-		", bones=", boneCount, ", expected ", expectedBoneCount,
-		"), assuming this puppet's MDLA layout wasn't recognized and stopping here"
+		", bones=", boneCount, ", expected ", expectedBoneCount, "), stopping here"
 	    );
 	    break;
 	}
 
+	const uint32_t sampleCount = clip.frameCount + 1;
+	bool valid = true;
+
+	// every track is a length-prefixed block of one value (or one 9-float transform) per sample
+	const auto skipTrack = [&] (uint32_t sampleBytes) {
+	    const uint32_t trackBytes = reader.nextUInt32 ();
+	    if (trackBytes != sampleCount * sampleBytes) {
+		valid = false;
+		return;
+	    }
+	    reader.base ().seekg (static_cast<std::streamoff> (trackBytes), std::ios::cur);
+	};
+	const auto skipFlaggedTracks = [&] (uint32_t count, uint32_t sampleBytes) {
+	    for (uint32_t i = 0; i < count && valid; i++) {
+		(void)reader.nextUInt32 ();
+		skipTrack (sampleBytes);
+	    }
+	};
+
 	clip.boneTracks.resize (boneCount);
 
-	for (uint32_t boneIndex = 0; boneIndex < boneCount; boneIndex++) {
-	    (void)reader.nextUInt32 (); // separator, always 0 in every sample seen
+	for (uint32_t boneIndex = 0; boneIndex < boneCount && valid; boneIndex++) {
+	    (void)reader.nextUInt32 (); // track flags
 	    const uint32_t trackBytes = reader.nextUInt32 ();
-	    const uint32_t sampleCount = clip.frameCount + 1;
-	    const uint32_t expectedBytes = sampleCount * 9 * sizeof (float);
 
-	    if (trackBytes != expectedBytes) {
-		sLog.error (
-		    "Puppet animation track length mismatch in clip ", clip.name, " (expected ", expectedBytes,
-		    ", got ", trackBytes, "), skipping"
-		);
-		reader.base ().seekg (static_cast<std::streamoff> (trackBytes), std::ios::cur);
-		continue;
+	    if (trackBytes != sampleCount * 9 * sizeof (float)) {
+		valid = false;
+		break;
 	    }
 
 	    auto& track = clip.boneTracks[boneIndex];
@@ -639,18 +642,73 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 	    }
 	}
 
-	clips.push_back (std::move (clip));
+	if (version >= 2) {
+	    skipFlaggedTracks (rig.extraCount, 9 * sizeof (float));
+	    skipFlaggedTracks (rig.constraintCount, sizeof (float));
+	}
+
+	if (version >= 3 && valid) {
+	    skipFlaggedTracks (reader.nextUInt32 (), sizeof (float));
 
-	if (clipIndex + 1 < clipCount) {
-	    const auto pos = static_cast<size_t> (reader.base ().tellg ());
-	    const auto next
-		= findNextPuppetClipHeader (data, pos, std::min (pos + 16384, data.size ()), expectedBoneCount);
-	    if (!next.has_value ()) {
-		sLog.error ("Could not resynchronize puppet animation data after clip ", clips.back ().name);
+	    if (valid && reader.next () != 0) {
+		skipFlaggedTracks (boneCount, sizeof (float));
+	    }
+	}
+
+	if (version >= 4 && valid && reader.next () != 0) {
+	    for (uint32_t mesh = 0; mesh < meshCount && valid; mesh++) {
+		if ((reader.nextUInt32 () & 1) == 0) {
+		    continue;
+		}
+
+		(void)reader.nextUInt32 ();
+		uint16_t count = 0;
+		reader.next (reinterpret_cast<char*> (&count), sizeof (count));
+
+		for (uint16_t i = 0; i < count && valid; i++) {
+		    reader.base ().seekg (sizeof (uint16_t), std::ios::cur);
+		    skipTrack (sizeof (float));
+		}
+	    }
+	}
+
+	if (version >= 5 && valid) {
+	    reader.base ().seekg (sizeof (uint32_t) * 6, std::ios::cur);
+	}
+
+	if (version >= 6 && valid && reader.next () != 0) {
+	    skipFlaggedTracks (boneCount, sizeof (float));
+	}
+
+	if ((flags & 1) && valid) {
+	    reader.base ().seekg (sizeof (uint16_t) + sizeof (uint32_t) * 4, std::ios::cur);
+	}
+
+	if (valid) {
+	    const uint32_t eventCount = reader.nextUInt32 ();
+
+	    for (uint32_t i = 0; i < eventCount && reader.base ().good (); i++) {
+		(void)reader.nextUInt32 (); // frame
+		(void)reader.nextNullTerminatedString ();
+	    }
+	}
+
+	if (!valid || !reader.base ().good ()) {
+	    reader.base ().clear ();
+	    sLog.error ("Puppet animation clip ", clip.name, " doesn't match the MDLA layout, stopping here");
+	    if (!valid) {
 		break;
 	    }
-	    reader.base ().seekg (static_cast<std::streamoff> (*next), std::ios::beg);
 	}
+
+	clips.push_back (std::move (clip));
+    }
+
+    if (clips.size () == clipCount && static_cast<size_t> (reader.base ().tellg ()) != sectionEnd) {
+	sLog.error (
+	    "Puppet MDLA clips end at ", static_cast<size_t> (reader.base ().tellg ()), " but the section ends at ",
+	    sectionEnd
+	);
     }
 
     return clips;
@@ -1164,6 +1222,8 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	this->m_puppetBlendWeights.clear ();
 	this->m_puppetAttachmentPoints.clear ();
 	this->m_puppetBoneWorldAnimated.clear ();
+	this->m_puppetSkinnedPositions.clear ();
+	this->m_puppetHasPhysics = false;
 
 	const auto blend = readPuppetBlendData (reader, layout->block, meshHeaderSize, layout->vertexStride);
 	if (blend.has_value ()) {
@@ -1189,6 +1249,11 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 
 		this->m_puppetBones = std::move (boneSet.bones);
 		this->m_puppetBoneWorldAnimated = worldBind;
+		this->m_puppetPhysicsState.assign (this->m_puppetBones.size (), {});
+		this->m_puppetPhysicsPreviousWorld.clear ();
+		this->m_puppetHasPhysics = std::ranges::any_of (this->m_puppetBones, [] (const PuppetBone& bone) {
+		    return bone.physics.simulated ();
+		});
 
 		// the MDLS "next section" field is trusted at face value below, but that's only been
 		// confirmed against MDLV0023 puppet-warp samples - other MDLV sub-formats (e.g. rope/particle
@@ -1214,12 +1279,22 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		    mdlaOffsetLooksValid = magicAt (mdlaOffset, mdlaMagic);
 		}
 
+		// MDLV header: tag, flags, a second field and the mesh count (2.8.42 sub_140261880)
+		uint32_t meshCount = 0;
+		if (data.size () >= 21) {
+		    std::memcpy (&meshCount, data.data () + 17, sizeof (meshCount));
+		}
+
+		// WE's section loop ends at an empty tag or the end of the file, puppets driven only by bone
+		// physics (3448290956's ahoge) have no MDLA
+		const bool noMoreSections = mdlaOffset + 1 >= data.size () || data[mdlaOffset] == 0;
+
 		std::vector<PuppetAnimationClip> clips;
 		if (mdlaOffsetLooksValid) {
 		    clips = parsePuppetAnimationClips (
-			data, reader, mdlaOffset, static_cast<uint32_t> (this->m_puppetBones.size ())
+			reader, mdlaOffset, static_cast<uint32_t> (this->m_puppetBones.size ()), boneSet, meshCount
 		    );
-		} else {
+		} else if (!noMoreSections) {
 		    sLog.error (
 			"Puppet MDLS data for ", *this->getImage ().model->puppet,
 			" doesn't lead to a recognizable MDLA section, skipping animation for this puppet"
@@ -1281,6 +1356,7 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		this->m_puppetActiveAnimations.clear ();
 		this->m_puppetAttachmentPoints.clear ();
 		this->m_puppetBoneWorldAnimated.clear ();
+		this->m_puppetHasPhysics = false;
 	    }
 	}
 
@@ -1295,9 +1371,8 @@ void CImage::updatePuppetPositionBuffer (const glm::vec2& size) {
     // once an animation clip is driving the mesh, its skinned output replaces the static bind pose
     // as the source of truth - the bind pose (m_puppetRawPositions) is kept around unchanged, since
     // skinning is recomputed from it fresh every frame, not accumulated from the previous frame
-    const auto& source = !this->m_puppetActiveAnimations.empty () && !this->m_puppetSkinnedPositions.empty ()
-	? this->m_puppetSkinnedPositions
-	: this->m_puppetRawPositions;
+    const auto& source
+	= !this->m_puppetSkinnedPositions.empty () ? this->m_puppetSkinnedPositions : this->m_puppetRawPositions;
 
     if (source.empty ()) {
 	return;
@@ -1367,7 +1442,7 @@ glm::vec3 lerp (const glm::vec3& a, const glm::vec3& b, float alpha) { return a
 }
 
 void CImage::updatePuppetSkinning () {
-    if (this->m_puppetActiveAnimations.empty () || this->m_puppetBones.empty ()) {
+    if (this->m_puppetBones.empty () || (this->m_puppetActiveAnimations.empty () && !this->m_puppetHasPhysics)) {
 	return;
     }
 
@@ -1425,7 +1500,7 @@ void CImage::updatePuppetSkinning () {
 	);
     }
 
-    if (samples.empty ()) {
+    if (samples.empty () && !this->m_puppetHasPhysics) {
 	return;
     }
 
@@ -1436,6 +1511,12 @@ void CImage::updatePuppetSkinning () {
 	const auto& bone = this->m_puppetBones[i];
 	animatedParents[i] = bone.parent;
 
+	// no animation layer playing, physics runs on the bind pose like WE
+	if (samples.empty ()) {
+	    animatedLocals[i] = bone.bindLocal;
+	    continue;
+	}
+
 	const glm::vec3 bindPosition (bone.bindLocal[3]);
 	glm::vec3 position = bindPosition;
 	bool positionBased = false;
@@ -1477,7 +1558,9 @@ void CImage::updatePuppetSkinning () {
 	animatedLocals[i] = local;
     }
 
-    const std::vector<glm::mat4> worldAnimated = composeBoneWorldTransforms (animatedParents, animatedLocals);
+    const std::vector<glm::mat4> worldAnimated = this->m_puppetHasPhysics
+	? this->composeBoneWorldTransformsWithPhysics (animatedParents, animatedLocals)
+	: composeBoneWorldTransforms (animatedParents, animatedLocals);
 
     // attachment points (see getAttachmentPointMeshTransform) need the live bone transforms independently
     // of the skin matrices below, which fold in the inverse bind pose
@@ -1525,6 +1608,67 @@ void CImage::updatePuppetSkinning () {
     this->updatePuppetPositionBuffer (this->m_size);
 }
 
+std::vector<glm::mat4> CImage::composeBoneWorldTransformsWithPhysics (
+    const std::vector<int>& parents, const std::vector<glm::mat4>& locals
+) {
+    const size_t count = parents.size ();
+    const float dt = std::max (g_Time - g_TimeLast, 0.0f);
+
+    // WE runs the physics on the bones' scene transforms (object world * bone), see sub_1401FDF90
+    const auto transform = this->resolveTransform (this->getImage ());
+    glm::mat4 object = glm::translate (glm::mat4 (1.0f), transform.origin);
+    object = glm::rotate (object, transform.angle, glm::vec3 (0.0f, 0.0f, 1.0f));
+    object = glm::scale (object, transform.scale);
+    const float objectScale
+	= (glm::length (glm::vec3 (object[0])) + glm::length (glm::vec3 (object[1])) + glm::length (glm::vec3 (object[2])))
+	/ 3.0f;
+
+    // the first frame has nothing to compare against and only records where the bones are
+    const bool hasPrevious = this->m_puppetPhysicsPreviousWorld.size () == count;
+    std::vector<glm::mat4> previous (count);
+    std::vector<glm::mat4> world (count);
+    std::vector<uint8_t> resolved (count, 0);
+
+    // parents first, a simulated parent moves its children
+    const auto resolve = [&] (const auto& self, size_t index) -> void {
+	if (resolved[index] != 0) {
+	    return;
+	}
+
+	resolved[index] = 1;
+	const int parent = parents[index];
+
+	if (parent >= 0 && static_cast<size_t> (parent) < count && resolved[parent] != 1) {
+	    self (self, static_cast<size_t> (parent));
+	    world[index] = world[parent] * locals[index];
+	} else {
+	    world[index] = locals[index];
+	}
+
+	glm::mat4 sceneWorld = object * world[index];
+	const auto& physics = this->m_puppetBones[index].physics;
+
+	if (physics.simulated () && hasPrevious) {
+	    world[index] = world[index]
+		* stepPuppetBonePhysics (
+			       physics, this->m_puppetPhysicsState[index], sceneWorld,
+			       this->m_puppetPhysicsPreviousWorld[index], dt, objectScale
+		);
+	    sceneWorld = object * world[index];
+	}
+
+	previous[index] = sceneWorld;
+	resolved[index] = 2;
+    };
+
+    for (size_t i = 0; i < count; i++) {
+	resolve (resolve, i);
+    }
+
+    this->m_puppetPhysicsPreviousWorld = std::move (previous);
+    return world;
+}
+
 std::optional<CImage::AttachmentPointTransform>
 CImage::getAttachmentPointMeshTransform (const std::string& name) const {
     if (this->m_puppetBoneWorldAnimated.empty ()) {
@@ -1599,7 +1743,7 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 	    // subset of triangles ends up on the wrong side, which looks like patchy missing geometry.
 	    glDisable (GL_CULL_FACE);
 	},
-	[this, pass] () {
+	[this] () {
 	    GLint currentFramebuffer = 0;
 	    glGetIntegerv (GL_DRAW_FRAMEBUFFER_BINDING, &currentFramebuffer);
 	    if (currentFramebuffer != static_cast<GLint> (this->getScene ().getFBO ()->getFramebuffer ())) {

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

@@ -8,6 +8,7 @@
 #include "WallpaperEngine/Render/Shaders/Shader.h"
 
 #include "../TextureProvider.h"
+#include "PuppetPhysics.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 #include <glm/mat4x4.hpp>
@@ -34,6 +35,7 @@ struct PuppetBone {
     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 };
+    PuppetBonePhysics physics {};
 };
 
 /** A single sampled TRS pose for one bone at one point in time, from the MDLA section */
@@ -160,6 +162,8 @@ public:
 private:
     bool loadPuppetMesh (const glm::vec2& size);
     void updatePuppetPositionBuffer (const glm::vec2& size);
+    [[nodiscard]] std::vector<glm::mat4>
+    composeBoneWorldTransformsWithPhysics (const std::vector<int>& parents, const std::vector<glm::mat4>& locals);
     /** Recomputes puppet vertex positions for the current animation time and re-uploads them */
     void updatePuppetSkinning ();
     void setupPuppetGeometryCallback (Effects::CPass* pass) const;
@@ -215,6 +219,10 @@ private:
     /** 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 = {};
+    /** Bone physics state and last frame's scene transforms of the simulated bones, empty until the first frame */
+    std::vector<PuppetBonePhysicsState> m_puppetPhysicsState = {};
+    std::vector<glm::mat4> m_puppetPhysicsPreviousWorld = {};
+    bool m_puppetHasPhysics = false;
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};

+ 379 - 0
src/WallpaperEngine/Render/Objects/PuppetPhysics.cpp

@@ -0,0 +1,379 @@
+#include "PuppetPhysics.h"
+
+#include "WallpaperEngine/Data/JSON.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstdlib>
+
+using JSON = WallpaperEngine::Data::JSON::JSON;
+
+// Ported from wallpaper64.exe 2.8.42. The math keeps WE's row-vector convention: a 3x3 is three rows, points
+// transform as v * M, and quaternions are w, x, y, z.
+
+namespace WallpaperEngine::Render::Objects {
+namespace {
+constexpr float kDegToRad = 0.017453292f;
+constexpr float kRadToDeg = 57.29578f;
+constexpr float kPi = 3.1415927f;
+constexpr float kTwoPi = 6.2831855f;
+
+struct Rows {
+    glm::vec3 r[3];
+};
+
+// w, x, y, z in the vec4's x, y, z, w
+using Quat = glm::vec4;
+
+const Quat kIdentity { 1.0f, 0.0f, 0.0f, 0.0f };
+
+glm::vec3 mulRow (const glm::vec3& v, const Rows& m) { return v.x * m.r[0] + v.y * m.r[1] + v.z * m.r[2]; }
+
+// sub_140215380
+Quat multiply (const Quat& a, const Quat& b) {
+    return {
+	b.x * a.x - b.y * a.y - b.z * a.z - b.w * a.w,
+	b.y * a.x + a.y * b.x + b.w * a.z - a.w * b.z,
+	b.z * a.x + a.z * b.x + a.w * b.y - b.w * a.y,
+	b.w * a.x + a.w * b.x + b.z * a.y - a.z * b.y,
+    };
+}
+
+// sub_140216070
+Quat slerp (const Quat& a, Quat b, float t) {
+    float cosine = glm::dot (a, b);
+
+    if (cosine < 0.0f) {
+	b = -b;
+	cosine = -cosine;
+    }
+
+    if (cosine > 0.99999988f) {
+	return a * (1.0f - t) + b * t;
+    }
+
+    const float angle = std::acos (cosine);
+    const float sine = std::sin (angle);
+
+    return (a * std::sin ((1.0f - t) * angle) + b * std::sin (angle * t)) / sine;
+}
+
+// sub_140217AC0
+Quat normalize (const Quat& q) {
+    const float length = std::sqrt (glm::dot (q, q));
+    return length > 0.0f ? q * (1.0f / length) : kIdentity;
+}
+
+// sub_1402167C0, the shortest rotation taking direction `from` onto `to`
+Quat rotationArc (const glm::vec3& from, const glm::vec3& to) {
+    const float cosine = glm::dot (from, to);
+
+    if (cosine >= 0.99999988f) {
+	return kIdentity;
+    }
+
+    if (cosine < -0.99999988f) {
+	glm::vec3 axis (-from.y, from.x, 0.0f);
+
+	if (glm::dot (axis, axis) < 0.00000011920929f) {
+	    axis = glm::vec3 (0.0f, -from.z, from.y);
+	}
+
+	axis *= 1.0f / std::sqrt (glm::dot (axis, axis));
+	return { std::cos (1.5707964f), axis.x, axis.y, axis.z };
+    }
+
+    const float s = std::sqrt ((cosine + 1.0f) + (cosine + 1.0f));
+    const glm::vec3 axis = glm::cross (from, to) * (1.0f / s);
+
+    return { s * 0.5f, axis.x, axis.y, axis.z };
+}
+
+// quaternion of the Euler angles, as built inline all over sub_1401FDF90
+Quat fromEuler (const glm::vec3& angles) {
+    const glm::vec3 half = angles * 0.5f;
+    const float c1 = std::cos (half.x), s1 = std::sin (half.x);
+    const float c2 = std::cos (half.y), s2 = std::sin (half.y);
+    const float c3 = std::cos (half.z), s3 = std::sin (half.z);
+
+    return {
+	s2 * s1 * s3 + c2 * c1 * c3,
+	c2 * s1 * c3 - s2 * c1 * s3,
+	c2 * s1 * s3 + s2 * c1 * c3,
+	c2 * c1 * s3 - s2 * s1 * c3,
+    };
+}
+
+Rows eulerRows (const glm::vec3& angles) {
+    const float cx = std::cos (angles.x), sx = std::sin (angles.x);
+    const float cy = std::cos (angles.y), sy = std::sin (angles.y);
+    const float cz = std::cos (angles.z), sz = std::sin (angles.z);
+
+    return { {
+	{ cy * cz, cy * sz, -sy },
+	{ sy * cz * sx - cx * sz, sy * sz * sx + cx * cz, sx * cy },
+	{ cx * cz * sy + sx * sz, cx * sz * sy - sx * cz, cx * cy },
+    } };
+}
+
+// sub_140216280
+Rows quatRows (const Quat& q) {
+    const float w = q.x, x = q.y, y = q.z, z = q.w;
+
+    return { {
+	{ 1.0f - 2.0f * (z * z + y * y), 2.0f * (z * w + x * y), 2.0f * (x * z - y * w) },
+	{ 2.0f * (x * y - z * w), 1.0f - 2.0f * (z * z + x * x), 2.0f * (x * w + y * z) },
+	{ 2.0f * (y * w + x * z), 2.0f * (y * z - x * w), 1.0f - 2.0f * (y * y + x * x) },
+    } };
+}
+
+glm::vec3 eulerFromRows (const Rows& n) {
+    const float z = std::atan2 (n.r[0].y, n.r[0].x);
+    const float y = std::atan2 (-n.r[0].z, std::sqrt (n.r[2].z * n.r[2].z + n.r[1].z * n.r[1].z));
+    const float sz = std::sin (z), cz = std::cos (z);
+    const float x = std::atan2 (sz * n.r[2].x - cz * n.r[2].y, cz * n.r[1].y - sz * n.r[1].x);
+
+    return { x, y, z };
+}
+
+float wrapAngle (float angle, bool locked) {
+    if (locked) {
+	return 0.0f;
+    }
+
+    if (angle < 0.0f) {
+	return std::fmod (angle - kPi, kTwoPi) + kPi;
+    }
+
+    return std::fmod (angle + kPi, kTwoPi) - kPi;
+}
+
+glm::vec3 parseVector (const std::string& text) {
+    glm::vec3 result (0.0f);
+    const char* cursor = text.c_str ();
+
+    for (int i = 0; i < 3 && *cursor != '\0'; i++) {
+	char* end = nullptr;
+	result[i] = std::strtof (cursor, &end);
+
+	if (end == cursor) {
+	    break;
+	}
+
+	cursor = end;
+    }
+
+    return result;
+}
+} // namespace
+
+PuppetBonePhysics PuppetBonePhysics::parse (const std::string& text) {
+    PuppetBonePhysics result;
+    const JSON json = JSON::parse (text, nullptr, false);
+
+    if (!json.is_object ()) {
+	return result;
+    }
+
+    const auto isTrue = [&json] (const char* key) {
+	const auto it = json.find (key);
+	return it != json.end () && it->is_boolean () && it->get<bool> ();
+    };
+    const auto number = [&json] (const char* key, float& out) {
+	const auto it = json.find (key);
+	if (it != json.end () && it->is_number ()) {
+	    out = it->get<float> ();
+	}
+    };
+    const auto vector = [&json] (const char* key, glm::vec3& out) {
+	const auto it = json.find (key);
+	if (it != json.end () && it->is_string ()) {
+	    out = parseVector (it->get<std::string> ());
+	    return true;
+	}
+	return false;
+    };
+
+    uint32_t flags = 0;
+    flags |= isTrue ("ik") ? InverseKinematics : 0;
+    flags |= isTrue ("r") ? Rotation : 0;
+    flags |= isTrue ("t") ? Translation : 0;
+    flags |= isTrue ("se") ? Simulate : 0;
+    flags |= isTrue ("re") ? SimulateRelative : 0;
+
+    // only "se" bones are ported: WE takes its "re" branch first when that is set, and IK chains are separate. No
+    // installed wallpaper uses either
+    if ((flags & Simulate) == 0 || (flags & SimulateRelative) != 0 || (flags & (Rotation | Translation)) == 0) {
+	return result;
+    }
+
+    flags |= isTrue ("ge") ? Gravity : 0;
+    vector ("gd", result.gravityDirection);
+    number ("m", result.mass);
+    number ("tf", result.translationFriction);
+    number ("rs", result.rotationStiffness);
+    number ("ts", result.translationStiffness);
+    number ("rf", result.rotationFriction);
+    vector ("tp", result.target);
+    number ("tm", result.maxTranslation);
+
+    float inertia = 0.0f;
+    if (json.contains ("ri") && json["ri"].is_number ()) {
+	number ("ri", inertia);
+	result.rotationInertia = 1.0f - inertia / 100.0f;
+    }
+    if (json.contains ("ti") && json["ti"].is_number ()) {
+	number ("ti", inertia);
+	result.translationInertia = 1.0f - inertia / 100.0f;
+    }
+
+    if (isTrue ("la")) {
+	flags |= AngleLimits;
+	vector ("lamin", result.angleMin);
+	vector ("lamax", result.angleMax);
+    }
+
+    if (isTrue ("lt")) {
+	flags |= TotalAngleLimit;
+	number ("ltmax", result.maxAngle);
+    }
+
+    // axis locks only count when all three keys are there, an axis set to false is locked
+    const auto locks = [&json, &flags] (const char* x, const char* y, const char* z, uint32_t firstFlag) {
+	const char* keys[] = { x, y, z };
+	for (const char* key : keys) {
+	    if (!json.contains (key) || !json[key].is_boolean ()) {
+		return;
+	    }
+	}
+	for (int i = 0; i < 3; i++) {
+	    if (!json[keys[i]].get<bool> ()) {
+		flags |= firstFlag << i;
+	    }
+	}
+    };
+    locks ("rax", "ray", "raz", LockRotationX);
+    locks ("tax", "tay", "taz", LockTranslationX);
+
+    result.flags = flags;
+    return result;
+}
+
+glm::mat4 stepPuppetBonePhysics (
+    const PuppetBonePhysics& physics, PuppetBonePhysicsState& state, const glm::mat4& world,
+    const glm::mat4& previousWorld, float dt, float objectScale
+) {
+    const uint32_t flags = physics.flags;
+    const Rows current { { glm::vec3 (world[0]), glm::vec3 (world[1]), glm::vec3 (world[2]) } };
+    const Rows previous { { glm::vec3 (previousWorld[0]), glm::vec3 (previousWorld[1]), glm::vec3 (previousWorld[2]) } };
+    const glm::vec3 currentOrigin (world[3]);
+    const glm::vec3 previousOrigin (previousWorld[3]);
+
+    // v * inverse, from world space into this bone's space
+    const glm::mat3 inverse = glm::inverse (glm::mat3 (world));
+    const auto toBone = [&inverse] (const glm::vec3& v) { return inverse * v; };
+
+    // where the target point was last frame, seen from the bone as it is now
+    const glm::vec3 previousTarget = toBone (mulRow (physics.target, previous) + previousOrigin - currentOrigin);
+    const glm::vec3 previousDirection = glm::normalize (previousTarget);
+    glm::vec3 localOffset = toBone (state.offset);
+
+    const glm::vec3 rotatedTarget = mulRow (physics.target, eulerRows (state.angles));
+    glm::vec3 predicted = rotatedTarget + localOffset;
+
+    if (flags & PuppetBonePhysics::Gravity) {
+	const glm::vec3 gravity = toBone (physics.gravityDirection) * (physics.mass * objectScale);
+	const glm::vec3 direction = glm::normalize (rotatedTarget);
+	predicted -= gravity - glm::dot (direction, gravity) * direction;
+
+	if (flags & PuppetBonePhysics::Translation) {
+	    state.velocity += physics.gravityDirection * (physics.mass * dt * 1000.0f);
+	}
+    }
+
+    const float distance = std::min (glm::length (predicted - previousTarget), dt * 900.0f);
+    const Quat arc = rotationArc (glm::normalize (predicted), previousDirection);
+
+    if ((flags & PuppetBonePhysics::Simulate) && (flags & PuppetBonePhysics::Rotation)) {
+	Quat& velocity = state.angularVelocity;
+
+	velocity = multiply (velocity, slerp (kIdentity, arc, std::min (distance * physics.rotationInertia * kDegToRad, 1.0f)));
+	velocity = multiply (
+	    velocity,
+	    slerp (kIdentity, fromEuler (-state.angles), std::min (physics.rotationStiffness * kDegToRad * dt, 1.0f))
+	);
+
+	if (flags & PuppetBonePhysics::TotalAngleLimit) {
+	    const float w = velocity.x;
+	    velocity = { 1.0f, velocity.y / w, velocity.z / w, velocity.w / w };
+
+	    for (int i = 1; i < 4; i++) {
+		float angle = std::atan (velocity[i]) * kRadToDeg * 2.0f;
+		angle = std::max (std::min (angle, physics.maxAngle), -physics.maxAngle);
+		velocity[i] = std::tan (angle * kDegToRad * 0.5f);
+	    }
+
+	    velocity = normalize (velocity);
+	}
+
+	// velocity is per 60 Hz frame
+	const Quat step = slerp (kIdentity, velocity, std::min (dt / 0.016666668f, 1.0f));
+	const Rows angles = eulerRows (state.angles);
+	const Rows rotation = quatRows (step);
+	const Rows combined { { mulRow (angles.r[0], rotation), mulRow (angles.r[1], rotation),
+				mulRow (angles.r[2], rotation) } };
+	const glm::vec3 next = eulerFromRows (combined);
+
+	state.angles = {
+	    wrapAngle (next.x, flags & PuppetBonePhysics::LockRotationX),
+	    wrapAngle (next.y, flags & PuppetBonePhysics::LockRotationY),
+	    wrapAngle (next.z, flags & PuppetBonePhysics::LockRotationZ),
+	};
+
+	if (flags & PuppetBonePhysics::AngleLimits) {
+	    const glm::vec3 clamped = glm::min (glm::max (state.angles, physics.angleMin), physics.angleMax);
+	    const Quat overshoot = fromEuler (state.angles - clamped);
+
+	    state.angles = clamped;
+	    velocity = multiply ({ overshoot.x, -overshoot.y, -overshoot.z, -overshoot.w }, velocity);
+	}
+
+	velocity = slerp (velocity, kIdentity, std::min (dt * physics.rotationFriction, 1.0f));
+    }
+
+    if ((flags & PuppetBonePhysics::Simulate) && (flags & PuppetBonePhysics::Translation)) {
+	const glm::vec3 pulled
+	    = state.offset - (state.offset + currentOrigin - previousOrigin) * physics.translationInertia;
+	glm::vec3 velocity = state.velocity - (dt * physics.translationStiffness) * pulled;
+	glm::vec3 offset = pulled + dt * velocity;
+
+	for (int axis = 0; axis < 3; axis++) {
+	    if (flags & (PuppetBonePhysics::LockTranslationX << axis)) {
+		const glm::vec3& direction = current.r[axis];
+		offset -= glm::dot (offset, direction) / glm::dot (direction, direction) * direction;
+	    }
+	}
+
+	const float limit = objectScale * physics.maxTranslation;
+	if (limit > 0.0f && limit * limit < glm::dot (offset, offset)) {
+	    offset *= limit / glm::length (offset);
+	}
+
+	velocity -= std::min (dt * physics.translationFriction, 1.0f) * velocity;
+
+	state.offset = offset;
+	state.velocity = velocity;
+	localOffset = toBone (offset);
+    }
+
+    // rows of the Euler rotation plus the offset, glm columns hold WE's rows
+    const Rows rotation = eulerRows (state.angles);
+
+    return {
+	glm::vec4 (rotation.r[0], 0.0f),
+	glm::vec4 (rotation.r[1], 0.0f),
+	glm::vec4 (rotation.r[2], 0.0f),
+	glm::vec4 (localOffset, 1.0f),
+    };
+}
+} // namespace WallpaperEngine::Render::Objects

+ 69 - 0
src/WallpaperEngine/Render/Objects/PuppetPhysics.h

@@ -0,0 +1,69 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <glm/glm.hpp>
+
+namespace WallpaperEngine::Render::Objects {
+/**
+ * Jiggle physics of one puppet bone, read from the JSON after its MDLS record the way wallpaper64.exe 2.8.42
+ * does (sub_140265C30). Angles are radians, stiffness/friction per second, rest target in bone space.
+ */
+struct PuppetBonePhysics {
+    enum Flags : uint32_t {
+	Simulate = 0x1,
+	SimulateRelative = 0x2,
+	Gravity = 0x4,
+	AngleLimits = 0x8,
+	TotalAngleLimit = 0x10,
+	Rotation = 0x1000,
+	Translation = 0x2000,
+	InverseKinematics = 0x4000,
+	LockRotationX = 0x100000,
+	LockRotationY = 0x200000,
+	LockRotationZ = 0x400000,
+	LockTranslationX = 0x800000,
+	LockTranslationY = 0x1000000,
+	LockTranslationZ = 0x2000000,
+    };
+
+    uint32_t flags = 0;
+    float rotationStiffness = 0.0f;
+    float translationStiffness = 0.0f;
+    float rotationFriction = 0.0f;
+    float translationFriction = 0.0f;
+    // stored as 1 - inertia / 100 like WE
+    float rotationInertia = 1.0f;
+    float translationInertia = 1.0f;
+    glm::vec3 gravityDirection { 0.0f };
+    float mass = 1000.0f;
+    glm::vec3 target { 0.0f };
+    float maxTranslation = 0.0f;
+    glm::vec3 angleMin { 0.0f };
+    glm::vec3 angleMax { 0.0f };
+    float maxAngle = 0.0f;
+
+    [[nodiscard]] bool simulated () const { return (flags & (Rotation | Translation)) != 0; }
+
+    static PuppetBonePhysics parse (const std::string& json);
+};
+
+struct PuppetBonePhysicsState {
+    // w, x, y, z
+    glm::vec4 angularVelocity { 1.0f, 0.0f, 0.0f, 0.0f };
+    glm::vec3 angles { 0.0f };
+    glm::vec3 offset { 0.0f };
+    glm::vec3 velocity { 0.0f };
+};
+
+/**
+ * One frame of a bone's physics (sub_1401FDF90). `world` is the bone's scene transform this frame before its own
+ * physics, `previousWorld` last frame's after it, `objectScale` the average axis scale of the object. Returns the
+ * matrix to apply in front of the bone's model-space transform (model * result).
+ */
+glm::mat4 stepPuppetBonePhysics (
+    const PuppetBonePhysics& physics, PuppetBonePhysicsState& state, const glm::mat4& world,
+    const glm::mat4& previousWorld, float dt, float objectScale
+);
+} // namespace WallpaperEngine::Render::Objects

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

@@ -276,7 +276,7 @@ inline float hashedNoise2D (int32_t seed, float x, float y) {
     };
     const auto flip = [] (float value, int32_t bit) { return bit != 0 ? -value : value; };
     // bit 0 and 1 flip the signs of x and y, bit 2 decides which one gets the bigger weight
-    const auto gradient = [&flip, gradientRatio] (int32_t h, float gx, float gy) {
+    const auto gradient = [&flip] (int32_t h, float gx, float gy) {
 	const float a = flip (gx, h & 1);
 	const float b = flip (gy, (h >> 1) & 1);
 	return (h & 4) != 0 ? b * gradientRatio + a : a * gradientRatio + b;

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

@@ -367,7 +367,7 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
     ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
 
     if (auto* property = container->object.tryGetProperty (name); property != nullptr) {
-	return container->adapter.getEngine ().dynamicToJs (*property);
+	return container->adapter.getEngine ().propertyToJs (*property, name);
     }
 
     static constexpr struct {
@@ -486,7 +486,7 @@ int scriptableobject_property_set (
     // 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);
+	container->adapter.getEngine ().assignPropertyJsValue (val, *property, name);
     }
 
     return 0;

+ 93 - 21
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -186,9 +186,9 @@ static bool updateVectorFromNumber (DynamicValue& source, float number) {
     }
 }
 
-static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source) {
+static bool jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source) {
     if (JS_IsException (val)) {
-	return;
+	return false;
     }
 
     int tag = JS_VALUE_GET_TAG (val);
@@ -200,19 +200,19 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
     // DynamicValueParser), and calling it here would zero out (e.g. scale -> 0, i.e. invisible)
     // any property whose script doesn't explicitly return on every path.
     if (tag == JS_TAG_UNDEFINED || tag == JS_TAG_UNINITIALIZED || tag == JS_TAG_NULL) {
-	return;
+	return false;
     }
 
     if (tag == JS_TAG_INT) {
 	if (!updateVectorFromNumber (source, static_cast<float> (JS_VALUE_GET_INT (val)))) {
 	    source.update (JS_VALUE_GET_INT (val), DynamicValue::UpdateSource::Script);
 	}
-	return;
+	return true;
     }
 
     if (tag == JS_TAG_BOOL) {
 	source.update (static_cast<bool> (JS_VALUE_GET_BOOL (val)), DynamicValue::UpdateSource::Script);
-	return;
+	return true;
     }
 
     if (JS_TAG_IS_FLOAT64 (tag)) {
@@ -221,14 +221,14 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 	if (!updateVectorFromNumber (source, number)) {
 	    source.update (number, DynamicValue::UpdateSource::Script);
 	}
-	return;
+	return true;
     }
 
     if (tag == JS_TAG_STRING) {
 	const char* str = JS_ToCString (ctx, val);
 	source.update (std::string (str == nullptr ? "" : str), DynamicValue::UpdateSource::Script);
 	JS_FreeCString (ctx, str);
-	return;
+	return true;
     }
 
     if (tag == JS_TAG_OBJECT) {
@@ -252,7 +252,7 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 		sLog.error ("Script returned a vector without numeric x and y components, keeping the previous value");
 	    }
 
-	    return;
+	    return false;
 	}
 
 	double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
@@ -262,25 +262,91 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 
 	if (!JS_IsNumber (z)) {
 	    source.update (glm::vec2 (xVal, yVal), DynamicValue::UpdateSource::Script);
-	    return;
+	    return true;
 	}
 
 	JS_ToFloat64 (ctx, &zVal, z);
 
 	if (!JS_IsNumber (w)) {
 	    source.update (glm::vec3 (xVal, yVal, zVal), DynamicValue::UpdateSource::Script);
-	    return;
+	    return true;
 	}
 
 	JS_ToFloat64 (ctx, &wVal, w);
 	source.update (glm::vec4 (xVal, yVal, zVal, wVal), DynamicValue::UpdateSource::Script);
+	return true;
     }
+
+    return false;
 }
 
 void ScriptEngine::assignJsValue (JSValue val, DynamicValue& target) const {
     jsToDynamicValue (this->m_context, val, target);
 }
 
+// Objects keep "angles" in radians, scripts get degrees: scenescript64 2.8.42 converts every property whose
+// descriptor has flag 4 on the way in (sub_1816208E0, * 57.29578) and out (sub_181620E10, * 0.017453292), and
+// wallpaper64 only sets that flag on the object "angles" property (sub_1401E0530)
+static bool isDegreeProperty (std::string_view name) { return name == "angles"; }
+
+static DynamicValue scaledAngles (const DynamicValue& value, float factor) {
+    switch (value.getType ()) {
+	case DynamicValue::Float:
+	    return DynamicValue (value.getFloat () * factor);
+	case DynamicValue::Vec2:
+	    return DynamicValue (value.getVec2 () * factor);
+	case DynamicValue::Vec3:
+	    return DynamicValue (value.getVec3 () * factor);
+	case DynamicValue::Vec4:
+	    return DynamicValue (value.getVec4 () * factor);
+	default:
+	    return value;
+    }
+}
+
+JSValue ScriptEngine::propertyToJs (DynamicValue& value, std::string_view name) const {
+    if (!isDegreeProperty (name)) {
+	return this->dynamicToJs (value);
+    }
+
+    DynamicValue degrees = scaledAngles (value, 57.29578f);
+
+    return this->dynamicToJs (degrees);
+}
+
+void ScriptEngine::assignPropertyJsValue (JSValue val, DynamicValue& target, std::string_view name) const {
+    if (!isDegreeProperty (name)) {
+	jsToDynamicValue (this->m_context, val, target);
+	return;
+    }
+
+    DynamicValue degrees = scaledAngles (target, 57.29578f);
+
+    if (!jsToDynamicValue (this->m_context, val, degrees)) {
+	return;
+    }
+
+    const DynamicValue radians = scaledAngles (degrees, 0.017453292f);
+
+    switch (radians.getType ()) {
+	case DynamicValue::Float:
+	    target.update (radians.getFloat (), DynamicValue::UpdateSource::Script);
+	    break;
+	case DynamicValue::Vec2:
+	    target.update (radians.getVec2 (), DynamicValue::UpdateSource::Script);
+	    break;
+	case DynamicValue::Vec3:
+	    target.update (radians.getVec3 (), DynamicValue::UpdateSource::Script);
+	    break;
+	case DynamicValue::Vec4:
+	    target.update (radians.getVec4 (), DynamicValue::UpdateSource::Script);
+	    break;
+	default:
+	    target.update (radians, DynamicValue::UpdateSource::Script);
+	    break;
+    }
+}
+
 ScriptEngine::ScriptEngine (Wallpapers::CScene& scene, Media::MediaSource& mediaSource) :
     m_scene (scene), m_mediaSource (mediaSource) {
     this->m_unregisterMediaUpdateCallback
@@ -997,7 +1063,7 @@ JSValue this_object_get_animation (
 } // namespace
 
 // thisObject.<property name>: lets a listener registered by a property's script write that property
-// from outside (`listener.thisObject['Inner Color'] = color`). func_data is [engine address, value address]
+// from outside (`listener.thisObject['Inner Color'] = color`). func_data is [engine address, value address, is angles]
 JSValue this_object_property (
     JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
 ) {
@@ -1009,12 +1075,16 @@ JSValue this_object_property (
     auto* engine = reinterpret_cast<ScriptEngine*> (static_cast<intptr_t> (engineAddress));
     auto* value = reinterpret_cast<DynamicValue*> (static_cast<intptr_t> (valueAddress));
 
+    int64_t degrees = 0;
+    JS_ToInt64 (ctx, &degrees, func_data[2]);
+    const std::string_view name = degrees != 0 ? "angles" : "";
+
     if (magic == 0) {
-	return engine->dynamicToJs (*value);
+	return engine->propertyToJs (*value, name);
     }
 
     if (argc > 0) {
-	engine->assignJsValue (argv[0], *value);
+	engine->assignPropertyJsValue (argv[0], *value, name);
     }
 
     return JS_UNDEFINED;
@@ -1038,17 +1108,19 @@ JSValue ScriptEngine::makeThisObject (DynamicValue& value, const std::string& pr
 	JSValue propertyData[] = {
 	    JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (this))),
 	    JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (&value))),
+	    JS_NewInt64 (this->m_context, propertyName == "angles" ? 1 : 0),
 	};
 	JSAtom atom = JS_NewAtom (this->m_context, propertyName.c_str ());
 
 	JS_DefinePropertyGetSet (
 	    this->m_context, handle, atom,
-	    JS_NewCFunctionData (this->m_context, this_object_property, 0, 0, 2, propertyData),
-	    JS_NewCFunctionData (this->m_context, this_object_property, 1, 1, 2, propertyData), JS_PROP_ENUMERABLE
+	    JS_NewCFunctionData (this->m_context, this_object_property, 0, 0, 3, propertyData),
+	    JS_NewCFunctionData (this->m_context, this_object_property, 1, 1, 3, propertyData), JS_PROP_ENUMERABLE
 	);
 	JS_FreeAtom (this->m_context, atom);
 	JS_FreeValue (this->m_context, propertyData[0]);
 	JS_FreeValue (this->m_context, propertyData[1]);
+	JS_FreeValue (this->m_context, propertyData[2]);
     }
 
     return handle;
@@ -1100,14 +1172,14 @@ void ScriptEngine::dispatchAnimationEvents () {
 	    );
 	    JS_SetPropertyStr (this->m_context, eventObject, "frame", JS_NewFloat64 (this->m_context, event.frame));
 
-	    JSValue args[] = { eventObject, this->dynamicToJs (module.value) };
+	    JSValue args[] = { eventObject, this->propertyToJs (module.value, module.propertyName) };
 	    JSValue result = this->call (module.module, 2, args, "animationEvent");
 
 	    if (JS_IsException (result)) {
 		logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
 	    } else {
 		// like update(), the handler's return value becomes the property's new value
-		jsToDynamicValue (this->m_context, result, module.value);
+		this->assignPropertyJsValue (result, module.value, module.propertyName);
 	    }
 
 	    JS_FreeValue (this->m_context, result);
@@ -1124,7 +1196,7 @@ void ScriptEngine::initializeModule (const std::string& key, LoadedModule& modul
     // init() receives the property's static/base value exactly once, before update() starts being
     // called every tick - scripts commonly stash it (e.g. audio-reactive scale scripts scaling a
     // captured base value) and would otherwise read undefined forever.
-    JSValue initArgs[] = { this->dynamicToJs (module.value) };
+    JSValue initArgs[] = { this->propertyToJs (module.value, module.propertyName) };
     const DynamicValue valueBeforeInit (module.value);
     JSValue initResult = this->call (module.module, 1, initArgs, "init");
 
@@ -1135,7 +1207,7 @@ void ScriptEngine::initializeModule (const std::string& key, LoadedModule& modul
 	&& valueBeforeInit.getString () == module.value.getString ()
     ) {
 	// init() may hand back a different starting value, unless something already moved the property meanwhile
-	jsToDynamicValue (this->m_context, initResult, module.value);
+	this->assignPropertyJsValue (initResult, module.value, module.propertyName);
     }
 
     JS_FreeValue (this->m_context, initResult);
@@ -1299,7 +1371,7 @@ void ScriptEngine::tick () {
 	    this->initializeModule (key, module);
 	}
 
-	JSValue args[] = { this->dynamicToJs (module.value) };
+	JSValue args[] = { this->propertyToJs (module.value, module.propertyName) };
 	JSValue result = this->call (module.module, 1, args, "update");
 	ScopeGuard guard ([result, args, this] () {
 	    JS_FreeValue (this->m_context, result);
@@ -1341,7 +1413,7 @@ void ScriptEngine::tick () {
 	    wasPulsing[key] = pulsingNow;
 	}
 
-	jsToDynamicValue (this->m_context, result, module.value);
+	this->assignPropertyJsValue (result, module.value, module.propertyName);
     }
 
     this->dispatchAnimationEvents ();

+ 4 - 0
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -13,6 +13,7 @@
 #include <memory>
 #include <optional>
 #include <string>
+#include <string_view>
 #include <unordered_map>
 #include <unordered_set>
 #include <vector>
@@ -91,6 +92,9 @@ public:
     // so exotic property setters (e.g. `thisLayer.origin = ...` from another layer's script) can
     // write through to the real property instead of silently discarding the assignment.
     void assignJsValue (JSValue val, DynamicValue& target) const;
+    // dynamicToJs()/assignJsValue() for a named object property: scripts see "angles" in degrees
+    JSValue propertyToJs (DynamicValue& value, std::string_view name) const;
+    void assignPropertyJsValue (JSValue val, DynamicValue& target, std::string_view name) const;
 
     /**
      * Evaluate a WallpaperEngine script's update() function.

+ 1 - 0
tools/headless_render.sh

@@ -23,6 +23,7 @@
 # - The engine is stopped as soon as the screenshot file appears (HEADLESS_RENDER_KEEP_RUNNING=1 disables
 #   that and runs until HEADLESS_RENDER_TIMEOUT, default 60s).
 # - HEADLESS_RENDER_SIZE=WxH changes the window size (default 1920x1080).
+# - LWE_HEADLESS_CURSOR=x,y puts the GPU path's pointer at that fraction of the output (default 0.5,0.5).
 # - --screenshot-delay is capped at 5000 frames by the engine (ApplicationContext.cpp).
 # - CImage.cpp's puppet/effect diagnostics are one-shot logs that fire on the first draw call,
 #   not a chosen frame.

+ 34 - 0
tools/package_release.sh

@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Packs a finished build into a relocatable tarball: the binary, its library, CEF's runtime files
+# and the shared kissfft, all found through the $ORIGIN runpath `cmake --install` sets. The install
+# tree also carries the dependencies' own tools, headers and static libs, those are left out.
+#
+# Usage:
+#   tools/package_release.sh <build-dir> <package-name> <output-dir>
+#
+# Writes <output-dir>/<package-name>.tar.gz with a single <package-name>/ folder inside.
+set -euo pipefail
+
+build_dir="$(realpath "$1")"
+name="$2"
+out_dir="$(realpath "$3")"
+
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+
+cmake --install "$build_dir" --prefix "$work/install" >/dev/null
+
+pkg="$work/$name"
+mkdir -p "$pkg/lib"
+cd "$work/install"
+cp -a linux-wallpaperengine liblinux-wallpaperengine-lib.so libcef.so libEGL.so libGLESv2.so \
+    libvk_swiftshader.so vk_swiftshader_icd.json chrome-sandbox icudtl.dat v8_context_snapshot.bin \
+    ./*.pak locales "$pkg/"
+cp -a lib/libkissfft-float.so* "$pkg/lib/"
+
+# CEF ships libcef.so with debug symbols, 1.4 GB instead of about 220 MB
+strip --strip-unneeded "$pkg/linux-wallpaperengine" "$pkg/liblinux-wallpaperengine-lib.so" "$pkg/libcef.so"
+
+mkdir -p "$out_dir"
+tar -czf "$out_dir/$name.tar.gz" -C "$work" "$name"
+echo "Wrote $out_dir/$name.tar.gz"