瀏覽代碼

added bone API

UwU 2 小時之前
父節點
當前提交
192c353f0f

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

@@ -363,7 +363,7 @@ PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
 
     for (uint32_t i = 0; i < boneCount; i++) {
 	// records start with a null-terminated name, empty for most rigs
-	(void)reader.nextNullTerminatedString ();
+	std::string name = reader.nextNullTerminatedString ();
 	(void)reader.nextUInt32 (); // type, unused
 	const int parent = reader.nextInt ();
 	const uint32_t matrixBytes = reader.nextUInt32 ();
@@ -398,7 +398,10 @@ PuppetBoneSet parsePuppetBones (const BinaryReader& reader, size_t mdlsOffset) {
 	const std::string physics = reader.nextNullTerminatedString ();
 
 	result.bones.push_back (
-	    PuppetBone { .parent = parent, .bindLocal = bindLocal, .physics = PuppetBonePhysics::parse (physics) }
+	    PuppetBone { .name = std::move (name),
+			 .parent = parent,
+			 .bindLocal = bindLocal,
+			 .physics = PuppetBonePhysics::parse (physics) }
 	);
     }
 
@@ -1275,6 +1278,10 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	this->m_puppetBlendWeights.clear ();
 	this->m_puppetAttachmentPoints.clear ();
 	this->m_puppetBoneWorldAnimated.clear ();
+	this->m_puppetBoneLocal.clear ();
+	this->m_puppetBoneScene.clear ();
+	this->m_puppetPoseScripted = false;
+	this->m_puppetPoseAnimated = false;
 	this->m_puppetSkinnedPositions.clear ();
 	this->m_puppetHasPhysics = false;
 
@@ -1402,6 +1409,8 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		this->m_puppetActiveAnimations.clear ();
 		this->m_puppetAttachmentPoints.clear ();
 		this->m_puppetBoneWorldAnimated.clear ();
+		this->m_puppetBoneLocal.clear ();
+		this->m_puppetBoneScene.clear ();
 		this->m_puppetHasPhysics = false;
 	    }
 	}
@@ -1487,8 +1496,8 @@ namespace {
 glm::vec3 lerp (const glm::vec3& a, const glm::vec3& b, float alpha) { return a + (b - a) * alpha; }
 }
 
-void CImage::updatePuppetSkinning () {
-    if (this->m_puppetBones.empty () || (this->m_puppetActiveAnimations.empty () && !this->m_puppetHasPhysics)) {
+void CImage::updatePuppetPose () {
+    if (!this->m_hasPuppetMesh || this->m_puppetBones.empty ()) {
 	return;
     }
 
@@ -1561,9 +1570,7 @@ void CImage::updatePuppetSkinning () {
 	);
     }
 
-    if (samples.empty () && !this->m_puppetHasPhysics) {
-	return;
-    }
+    this->m_puppetPoseAnimated = !samples.empty () || this->m_puppetHasPhysics || this->m_puppetPoseScripted;
 
     // q and -q are the same rotation, blends take the one on the same side
     const auto nlerp = [] (const glm::quat& a, glm::quat b, float t) {
@@ -1630,14 +1637,15 @@ void CImage::updatePuppetSkinning () {
 	    * glm::scale (glm::mat4 (1.0f), scale);
     }
 
-    const std::vector<glm::mat4> worldAnimated = this->m_puppetHasPhysics
-	? this->composeBoneWorldTransformsWithPhysics (animatedParents, animatedLocals)
-	: composeBoneWorldTransforms (animatedParents, animatedLocals);
+    this->composePuppetPose (animatedParents, animatedLocals);
+}
 
-    // attachment points (see getAttachmentPointMeshTransform) need the live bone transforms independently
-    // of the skin matrices below, which fold in the inverse bind pose
-    this->m_puppetBoneWorldAnimated = worldAnimated;
+void CImage::updatePuppetSkinning () {
+    if (!this->m_puppetPoseAnimated || this->m_puppetBoneWorldAnimated.size () != this->m_puppetBones.size ()) {
+	return;
+    }
 
+    const auto& worldAnimated = this->m_puppetBoneWorldAnimated;
     std::vector<glm::mat4> skinMatrices (this->m_puppetBones.size ());
     for (size_t i = 0; i < this->m_puppetBones.size (); i++) {
 	skinMatrices[i] = worldAnimated[i] * this->m_puppetBones[i].inverseBindWorld;
@@ -1680,25 +1688,25 @@ 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
-) {
+void CImage::composePuppetPose (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);
+    // sub_1401FDF90 swaps the current and previous scene matrices first, so whatever a script wrote into the current
+    // ones last frame is what the physics compares against
+    const bool hasPrevious = this->m_puppetBoneScene.size () == count;
+    if (hasPrevious) {
+	this->m_puppetPhysicsPreviousWorld = this->m_puppetBoneScene;
+    }
+
+    // WE runs the physics on the bones' scene transforms (object world * bone)
+    const glm::mat4 object = this->puppetObjectWorld ();
     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<glm::mat4> model (count);
+    std::vector<glm::mat4> scene (count);
     std::vector<uint8_t> resolved (count, 0);
 
     // parents first, a simulated parent moves its children
@@ -1712,24 +1720,24 @@ std::vector<glm::mat4> CImage::composeBoneWorldTransformsWithPhysics (
 
 	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];
+	    model[index] = model[parent] * locals[index];
 	} else {
-	    world[index] = locals[index];
+	    model[index] = locals[index];
 	}
 
-	glm::mat4 sceneWorld = object * world[index];
+	scene[index] = object * model[index];
 	const auto& physics = this->m_puppetBones[index].physics;
 
+	// the first frame has nothing to compare against and only records where the bones are
 	if (physics.simulated () && hasPrevious) {
-	    world[index] = world[index]
+	    model[index] = model[index]
 		* stepPuppetBonePhysics (
-			       physics, this->m_puppetPhysicsState[index], sceneWorld,
+			       physics, this->m_puppetPhysicsState[index], scene[index],
 			       this->m_puppetPhysicsPreviousWorld[index], dt, objectScale
 		);
-	    sceneWorld = object * world[index];
+	    scene[index] = object * model[index];
 	}
 
-	previous[index] = sceneWorld;
 	resolved[index] = 2;
     };
 
@@ -1737,8 +1745,81 @@ std::vector<glm::mat4> CImage::composeBoneWorldTransformsWithPhysics (
 	resolve (resolve, i);
     }
 
-    this->m_puppetPhysicsPreviousWorld = std::move (previous);
-    return world;
+    this->m_puppetBoneLocal = locals;
+    this->m_puppetBoneWorldAnimated = std::move (model);
+    this->m_puppetBoneScene = std::move (scene);
+}
+
+glm::mat4 CImage::puppetObjectWorld () const {
+    // WE's bone world (image vtable slot 16, sub_1401FD3F0) also moves by the alignment offset (sub_1402066A0),
+    // left out: the size it uses for puppets isn't traced yet
+    return this->getScene ().objectWorldMatrix (this->getObject ());
+}
+
+bool CImage::hasPuppetPose () const {
+    return !this->m_puppetBones.empty () && this->m_puppetBoneScene.size () == this->m_puppetBones.size ();
+}
+
+int CImage::findPuppetBone (const std::string& name) const {
+    for (size_t i = 0; i < this->m_puppetBones.size (); i++) {
+	if (this->m_puppetBones[i].name == name) {
+	    return static_cast<int> (i);
+	}
+    }
+
+    return -1;
+}
+
+const glm::mat4& CImage::getPuppetBoneTransform (int bone) const { return this->m_puppetBoneScene[bone]; }
+
+void CImage::setPuppetBoneTransform (int bone, const glm::mat4& transform) {
+    // sub_14020F350: only this bone, its children keep their matrices until the next update
+    this->m_puppetBoneScene[bone] = transform;
+    this->m_puppetBoneWorldAnimated[bone] = glm::inverse (this->puppetObjectWorld ()) * transform;
+    this->m_puppetPoseScripted = true;
+    this->m_puppetPoseAnimated = true;
+}
+
+const glm::mat4& CImage::getPuppetLocalBoneTransform (int bone) const { return this->m_puppetBoneLocal[bone]; }
+
+void CImage::setPuppetLocalBoneTransform (int bone, const glm::mat4& transform) {
+    this->m_puppetBoneLocal[bone] = transform;
+
+    // sub_14020DB40: the bone and every later bone whose parent was touched, in index order
+    const glm::mat4 object = this->puppetObjectWorld ();
+    std::set<int> touched;
+
+    const int count = static_cast<int> (this->m_puppetBones.size ());
+
+    for (int index = bone; index < count; index++) {
+	const int parent = this->m_puppetBones[index].parent;
+
+	if (index != bone && !touched.contains (parent)) {
+	    continue;
+	}
+
+	touched.insert (index);
+	this->m_puppetBoneWorldAnimated[index] = parent < 0 || parent >= count
+	    ? this->m_puppetBoneLocal[index]
+	    : this->m_puppetBoneWorldAnimated[parent] * this->m_puppetBoneLocal[index];
+	this->m_puppetBoneScene[index] = object * this->m_puppetBoneWorldAnimated[index];
+    }
+
+    this->m_puppetPoseScripted = true;
+    this->m_puppetPoseAnimated = true;
+}
+
+void CImage::applyPuppetBonePhysicsImpulse (int bone, const glm::vec3& directional, const glm::vec3& angularDegrees) {
+    if (bone >= 0 && static_cast<size_t> (bone) < this->m_puppetPhysicsState.size ()) {
+	applyPuppetBoneImpulse (this->m_puppetPhysicsState[bone], directional, angularDegrees);
+    }
+}
+
+void CImage::resetPuppetBonePhysics (int bone) {
+    // sub_140210E10
+    if (bone >= 0 && static_cast<size_t> (bone) < this->m_puppetPhysicsState.size ()) {
+	this->m_puppetPhysicsState[bone] = {};
+    }
 }
 
 std::optional<CImage::AttachmentPointTransform>

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

@@ -31,6 +31,7 @@ class CPass;
 namespace WallpaperEngine::Render::Objects {
 /** A puppet skeleton bone, parsed from the MDLS section of the puppet .mdl */
 struct PuppetBone {
+    std::string name;
     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 };
@@ -135,6 +136,25 @@ public:
     [[nodiscard]] std::optional<AttachmentPointTransform>
     getAttachmentPointMeshTransform (const std::string& name) const;
 
+    /** Per frame puppet update (WE's image update, sub_1401FDF90): animation, physics and the bone matrices, before
+     *  the scripts run so they read and change this frame's pose */
+    void updatePuppetPose ();
+
+    /**
+     * Bone access for IImageLayer scripts (wallpaper64 2.8.42 sub_140211070). Matrices are column-vector glm, the
+     * transpose of WE's row-vector ones with the same 16 floats in memory. Scene matrices are object world * bone,
+     * local ones relative to the parent bone. Only valid while hasPuppetPose(), for bones below the bone count.
+     */
+    [[nodiscard]] bool hasPuppetPose () const;
+    [[nodiscard]] const std::vector<PuppetBone>& getPuppetBones () const { return this->m_puppetBones; }
+    [[nodiscard]] int findPuppetBone (const std::string& name) const;
+    [[nodiscard]] const glm::mat4& getPuppetBoneTransform (int bone) const;
+    void setPuppetBoneTransform (int bone, const glm::mat4& transform);
+    [[nodiscard]] const glm::mat4& getPuppetLocalBoneTransform (int bone) const;
+    void setPuppetLocalBoneTransform (int bone, const glm::mat4& transform);
+    void applyPuppetBonePhysicsImpulse (int bone, const glm::vec3& directional, const glm::vec3& angularDegrees);
+    void resetPuppetBonePhysics (int bone);
+
 protected:
     void setupPasses ();
     void rebuildActivePasses ();
@@ -172,9 +192,9 @@ 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 composePuppetPose (const std::vector<int>& parents, const std::vector<glm::mat4>& locals);
+    [[nodiscard]] glm::mat4 puppetObjectWorld () const;
+    /** Skins the puppet vertices with the current bone matrices and re-uploads them */
     void updatePuppetSkinning ();
     void setupPuppetGeometryCallback (Effects::CPass* pass) const;
     ResolvedTransform updateGeometryBuffers ();
@@ -227,12 +247,19 @@ private:
 
     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 */
+     *  to the bind pose and is refreshed every frame by updatePuppetPose */
     std::vector<glm::mat4> m_puppetBoneWorldAnimated = {};
-    /** Bone physics state and last frame's scene transforms of the simulated bones, empty until the first frame */
+    /** the bones' local matrices this frame (WE P+784) and their scene matrices (P+832), empty until the first update */
+    std::vector<glm::mat4> m_puppetBoneLocal = {};
+    std::vector<glm::mat4> m_puppetBoneScene = {};
+    /** Bone physics state and last frame's scene transforms, empty until the first frame */
     std::vector<PuppetBonePhysicsState> m_puppetPhysicsState = {};
     std::vector<glm::mat4> m_puppetPhysicsPreviousWorld = {};
     bool m_puppetHasPhysics = false;
+    /** a script wrote bone matrices, the mesh has to be skinned from then on */
+    bool m_puppetPoseScripted = false;
+    /** the pose differs from the bind pose (animation, physics or scripts), so render skins the mesh */
+    bool m_puppetPoseAnimated = false;
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};

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

@@ -376,4 +376,9 @@ glm::mat4 stepPuppetBonePhysics (
 	glm::vec4 (localOffset, 1.0f),
     };
 }
+
+void applyPuppetBoneImpulse (PuppetBonePhysicsState& state, const glm::vec3& directional, const glm::vec3& angularDegrees) {
+    state.velocity += directional;
+    state.angularVelocity = multiply (state.angularVelocity, fromEuler (angularDegrees * kDegToRad));
+}
 } // namespace WallpaperEngine::Render::Objects

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

@@ -66,4 +66,10 @@ glm::mat4 stepPuppetBonePhysics (
     const PuppetBonePhysics& physics, PuppetBonePhysicsState& state, const glm::mat4& world,
     const glm::mat4& previousWorld, float dt, float objectScale
 );
+
+/**
+ * Script `applyBonePhysicsImpulse` (sub_140210990): the directional impulse is added to the translation velocity,
+ * the angular one (degrees) is turned into a quaternion and multiplied onto the angular velocity.
+ */
+void applyPuppetBoneImpulse (PuppetBonePhysicsState& state, const glm::vec3& directional, const glm::vec3& angularDegrees);
 } // namespace WallpaperEngine::Render::Objects

+ 8 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -420,6 +420,14 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     this->m_outputSize = { viewport.z, viewport.w };
     timeStep ("updateMouse", [&] { this->updateMouse (viewport); });
 
+    // WE updates every object before cursor events and scripts (sub_1401891A0 from sub_14017FA70), so scripts read
+    // and change this frame's puppet bones and the render draws what they left
+    for (const auto& cur : this->m_objectsByRenderOrder) {
+	if (cur->is<Objects::CImage> ()) {
+	    cur->as<Objects::CImage> ()->updatePuppetPose ();
+	}
+    }
+
     // after the tick, so a layer a script moves this frame (e.g. onto input.cursorWorldPosition) is hit tested where it
     // is now
     timeStep ("script tick", [&] { this->getScriptEngine ().tick (); });

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

@@ -1,6 +1,9 @@
 #include "ScriptableObjectAdapter.h"
 
+#include <glm/gtc/type_ptr.hpp>
+
 #include <algorithm>
+#include <cmath>
 #include <cstring>
 #include <ranges>
 #include <utility>
@@ -350,6 +353,271 @@ JSValue scriptableobject_video_texture_call (
     return videotexture_instantiate (ctx, *texture->getPlayer (), *engine);
 }
 
+namespace {
+enum BoneCall {
+    GetBoneCount,
+    GetBoneTransform,
+    SetBoneTransform,
+    GetLocalBoneTransform,
+    SetLocalBoneTransform,
+    GetLocalBoneAngles,
+    SetLocalBoneAngles,
+    GetLocalBoneOrigin,
+    SetLocalBoneOrigin,
+    GetBoneIndex,
+    GetBoneParentIndex,
+    ApplyBonePhysicsImpulse,
+    ResetBonePhysicsSimulation,
+};
+
+// WE's own Mat4 from baseclasses.js: m[i] is float i of the native matrix (scenescript64 sub_1816214A0)
+JSValue makeMat4 (JSContext* ctx, const WallpaperEngine::Scripting::ScriptEngine& engine, const glm::mat4& matrix) {
+    JSValue prototype = JS_GetPropertyStr (ctx, engine.getGlobalThis (), "_Mat4");
+    JSValue result = JS_IsObject (prototype) ? JS_NewObjectProto (ctx, prototype) : JS_NewObject (ctx);
+    JS_FreeValue (ctx, prototype);
+
+    JSValue values = JS_NewArray (ctx);
+    const float* floats = glm::value_ptr (matrix);
+    for (uint32_t i = 0; i < 16; i++) {
+	JS_SetPropertyUint32 (ctx, values, i, JS_NewFloat64 (ctx, floats[i]));
+    }
+    JS_SetPropertyStr (ctx, result, "m", values);
+
+    return result;
+}
+
+// elements that aren't numbers keep the identity's value, like sub_1816214A0
+glm::mat4 readMat4 (JSContext* ctx, JSValueConst value) {
+    glm::mat4 result (1.0f);
+
+    if (!JS_IsObject (value)) {
+	return result;
+    }
+
+    JSValue values = JS_GetPropertyStr (ctx, value, "m");
+    float* floats = glm::value_ptr (result);
+
+    if (JS_IsObject (values)) {
+	for (uint32_t i = 0; i < 16; i++) {
+	    JSValue element = JS_GetPropertyUint32 (ctx, values, i);
+	    double number = 0.0;
+
+	    if (JS_IsNumber (element) && JS_ToFloat64 (ctx, &number, element) == 0) {
+		floats[i] = static_cast<float> (number);
+	    }
+
+	    JS_FreeValue (ctx, element);
+	}
+    }
+
+    JS_FreeValue (ctx, values);
+    return result;
+}
+
+glm::vec3 readVec3 (JSContext* ctx, JSValueConst value) {
+    glm::vec3 result (0.0f);
+
+    if (!JS_IsObject (value)) {
+	return result;
+    }
+
+    const char* names[] = { "x", "y", "z" };
+    for (int i = 0; i < 3; i++) {
+	JSValue component = JS_GetPropertyStr (ctx, value, names[i]);
+	double number = 0.0;
+
+	if (JS_IsNumber (component) && JS_ToFloat64 (ctx, &number, component) == 0) {
+	    result[i] = static_cast<float> (number);
+	}
+
+	JS_FreeValue (ctx, component);
+    }
+
+    return result;
+}
+
+// getLocalBoneAngles (sub_14020FA10), on WE's row-major floats
+glm::vec3 localBoneAngles (const glm::mat4& local) {
+    const float* m = glm::value_ptr (local);
+    const float z = std::atan2 (m[1], m[0]);
+    const float y = std::atan2 (-m[2], std::sqrt (m[6] * m[6] + m[10] * m[10]));
+    const float sz = std::sin (z);
+    const float cz = std::cos (z);
+    const float x = std::atan2 (sz * m[8] - cz * m[9], cz * m[5] - sz * m[4]);
+
+    return { x, y, z };
+}
+
+// setLocalBoneAngles (sub_14020FCE0): the rotation rows are replaced, scale dropped, the origin kept
+void setLocalBoneAngles (glm::mat4& local, 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);
+    float* m = glm::value_ptr (local);
+
+    m[0] = cy * cz;
+    m[1] = cy * sz;
+    m[2] = -sy;
+    m[3] = 0.0f;
+    m[4] = sy * cz * sx - cx * sz;
+    m[5] = sy * sz * sx + cx * cz;
+    m[6] = sx * cy;
+    m[7] = 0.0f;
+    m[8] = cx * cz * sy + sx * sz;
+    m[9] = cx * sz * sy - sx * cz;
+    m[10] = cx * cy;
+    m[11] = 0.0f;
+}
+} // namespace
+
+// thisLayer bone calls (wallpaper64 2.8.42 image methods, sub_140211070). A bone is a number (index) or a string
+// (name); anything else does nothing, like a failed lookup
+JSValue scriptableobject_bone_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    using WallpaperEngine::Render::Objects::CImage;
+
+    int64_t objectAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &objectAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* object
+	= reinterpret_cast<WallpaperEngine::Scripting::ScriptableObject*> (static_cast<intptr_t> (objectAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+
+    // both index lookups write -1 before looking at the layer
+    const JSValue fallback = magic == GetBoneIndex || magic == GetBoneParentIndex ? JS_NewInt32 (ctx, -1) : JS_UNDEFINED;
+
+    if (!object->is<CImage> ()) {
+	return fallback;
+    }
+
+    auto* image = object->as<CImage> ();
+    const auto& bones = image->getPuppetBones ();
+    const int count = static_cast<int> (bones.size ());
+
+    if (count == 0) {
+	return fallback;
+    }
+
+    if (magic == GetBoneCount) {
+	return JS_NewInt32 (ctx, count);
+    }
+
+    const JSValueConst boneArgument = argc > 0 ? argv[0] : JS_UNDEFINED;
+    std::optional<int> index;
+    std::optional<std::string> name;
+
+    if (JS_IsNumber (boneArgument)) {
+	int32_t value = 0;
+	JS_ToInt32 (ctx, &value, boneArgument);
+	index = value;
+    } else if (JS_IsString (boneArgument)) {
+	const char* value = JS_ToCString (ctx, boneArgument);
+	if (value != nullptr) {
+	    name = value;
+	    JS_FreeCString (ctx, value);
+	}
+    }
+
+    if (!index.has_value () && !name.has_value ()) {
+	return fallback;
+    }
+
+    if (magic == GetBoneIndex) {
+	// names only, an index argument or an empty name finds nothing
+	if (!name.has_value () || name->empty ()) {
+	    return fallback;
+	}
+
+	return JS_NewInt32 (ctx, image->findPuppetBone (*name));
+    }
+
+    if (magic == GetBoneParentIndex) {
+	// by name the first bone of that name that has a parent (sub_140210860)
+	for (int bone = 0; bone < count; bone++) {
+	    const bool matches = index.has_value () ? bone == *index : !name->empty () && bones[bone].name == *name;
+
+	    if (matches && bones[bone].parent != -1) {
+		return JS_NewInt32 (ctx, bones[bone].parent);
+	    }
+	}
+
+	return fallback;
+    }
+
+    // an empty name is the first bone here (sub_140210990, sub_140210E10), the other calls need a match
+    int bone = -1;
+    if (index.has_value ()) {
+	bone = *index;
+    } else if (magic == ApplyBonePhysicsImpulse || magic == ResetBonePhysicsSimulation) {
+	bone = name->empty () ? 0 : image->findPuppetBone (*name);
+    } else if (!name->empty ()) {
+	bone = image->findPuppetBone (*name);
+    }
+
+    if (bone < 0 || bone >= count) {
+	return fallback;
+    }
+
+    if (magic == ApplyBonePhysicsImpulse) {
+	image->applyPuppetBonePhysicsImpulse (
+	    bone, readVec3 (ctx, argc > 1 ? argv[1] : JS_UNDEFINED), readVec3 (ctx, argc > 2 ? argv[2] : JS_UNDEFINED)
+	);
+	return JS_UNDEFINED;
+    }
+
+    if (magic == ResetBonePhysicsSimulation) {
+	image->resetPuppetBonePhysics (bone);
+	return JS_UNDEFINED;
+    }
+
+    // the matrices exist from the layer's first update on
+    if (!image->hasPuppetPose ()) {
+	return fallback;
+    }
+
+    const JSValueConst value = argc > 1 ? argv[1] : JS_UNDEFINED;
+    const auto makeVec3 = [engine] (const glm::vec3& vector) {
+	WallpaperEngine::Data::Model::DynamicValue dynamic (vector);
+	return engine->getAdapters ().vec3->instantiate (dynamic);
+    };
+
+    switch (magic) {
+	case GetBoneTransform:
+	    return makeMat4 (ctx, *engine, image->getPuppetBoneTransform (bone));
+	case SetBoneTransform:
+	    image->setPuppetBoneTransform (bone, readMat4 (ctx, value));
+	    break;
+	case GetLocalBoneTransform:
+	    return makeMat4 (ctx, *engine, image->getPuppetLocalBoneTransform (bone));
+	case SetLocalBoneTransform:
+	    image->setPuppetLocalBoneTransform (bone, readMat4 (ctx, value));
+	    break;
+	case GetLocalBoneAngles:
+	    return makeVec3 (localBoneAngles (image->getPuppetLocalBoneTransform (bone)));
+	case SetLocalBoneAngles: {
+	    glm::mat4 local = image->getPuppetLocalBoneTransform (bone);
+	    setLocalBoneAngles (local, readVec3 (ctx, value));
+	    image->setPuppetLocalBoneTransform (bone, local);
+	    break;
+	}
+	case GetLocalBoneOrigin:
+	    return makeVec3 (glm::vec3 (image->getPuppetLocalBoneTransform (bone)[3]));
+	case SetLocalBoneOrigin: {
+	    // sub_140210250: floats 12..14, the rest stays
+	    glm::mat4 local = image->getPuppetLocalBoneTransform (bone);
+	    local[3] = glm::vec4 (readVec3 (ctx, value), local[3].w);
+	    image->setPuppetLocalBoneTransform (bone, local);
+	    break;
+	}
+	default:
+	    break;
+    }
+
+    return JS_UNDEFINED;
+}
+
 JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
     JSClassID classId = 0;
 
@@ -417,6 +685,37 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 	}
     }
 
+    static constexpr struct {
+	const char* name;
+	int magic;
+	int length;
+    } boneCalls[] = {
+	{ "getBoneCount", GetBoneCount, 0 },
+	{ "getBoneTransform", GetBoneTransform, 1 },
+	{ "setBoneTransform", SetBoneTransform, 2 },
+	{ "getLocalBoneTransform", GetLocalBoneTransform, 1 },
+	{ "setLocalBoneTransform", SetLocalBoneTransform, 2 },
+	{ "getLocalBoneAngles", GetLocalBoneAngles, 1 },
+	{ "setLocalBoneAngles", SetLocalBoneAngles, 2 },
+	{ "getLocalBoneOrigin", GetLocalBoneOrigin, 1 },
+	{ "setLocalBoneOrigin", SetLocalBoneOrigin, 2 },
+	{ "getBoneIndex", GetBoneIndex, 1 },
+	{ "getBoneParentIndex", GetBoneParentIndex, 1 },
+	{ "applyBonePhysicsImpulse", ApplyBonePhysicsImpulse, 3 },
+	{ "resetBonePhysicsSimulation", ResetBonePhysicsSimulation, 1 },
+    };
+
+    for (const auto& call : boneCalls) {
+	if (std::strcmp (name, call.name) == 0) {
+	    JSValue data[] = {
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))),
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->adapter.getEngine ()))),
+	    };
+
+	    return JS_NewCFunctionData (ctx, scriptableobject_bone_call, call.length, call.magic, 2, data);
+	}
+    }
+
     if (std::strcmp (name, "getVideoTexture") == 0) {
 	JSValue data[] = {
 	    JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))),