Explorar el Código

plenty of fixes

UwU hace 1 mes
padre
commit
e9294cf282

+ 1 - 1
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -977,7 +977,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    this->settings.audio.ambientVolume = std::max (0, std::min (*this->settings.audio.ambientVolume, 128));
 	}
 	this->settings.screenshot.delay
-	    = std::max<uint32_t> (0, std::min<uint32_t> (this->settings.screenshot.delay, 5));
+	    = std::max<uint32_t> (0, std::min<uint32_t> (this->settings.screenshot.delay, 5000));
 
 	// std::cout directly, in case logging is disabled, so this is still visible
 	std::stringbuf buffer;

+ 3 - 0
src/WallpaperEngine/Data/Model/Object.h

@@ -27,6 +27,9 @@ struct ObjectData {
     std::optional<int> parent;
     /** Name of a named attachment point on the parent's puppet rig to follow, if any */
     std::optional<std::string> attachment;
+    /** Explicit paint-order override (scene.json's "sortorder") - lower draws first (further back).
+     *  Falls back to this object's array position when absent. */
+    std::optional<int> sortOrder;
     UserSettingUniquePtr origin;
     /** Transform fields for generic scene/group objects. Typed objects keep their own transform fields. */
     UserSettingUniquePtr groupScale;

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

@@ -35,6 +35,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .dependencies = parseDependencies (it),
 	    .parent = it.optional<int> ("parent"),
 	    .attachment = it.optional<std::string> ("attachment"),
+	    .sortOrder = it.optional<int> ("sortorder"),
 	    .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)),
@@ -59,6 +60,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .dependencies = parseDependencies (it),
 	    .parent = it.optional<int> ("parent"),
 	    .attachment = it.optional<std::string> ("attachment"),
+	    .sortOrder = it.optional<int> ("sortorder"),
 	    .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)),

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

@@ -11,6 +11,7 @@
 
 #include <GLFW/glfw3native.h>
 
+#include <cstdlib>
 #include <unistd.h>
 
 using namespace WallpaperEngine::Render::Drivers;
@@ -32,7 +33,7 @@ GLFWOpenGLDriver::GLFWOpenGLDriver (const char* windowTitle, ApplicationContext&
     // 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);
+    glfwWindowHint (GLFW_VISIBLE, getenv ("LWE_DEBUG_VISIBLE_WINDOW") ? GLFW_TRUE : GLFW_FALSE);
     glfwWindowHintString (GLFW_X11_CLASS_NAME, "linux-wallpaperengine");
     glfwWindowHintString (GLFW_X11_INSTANCE_NAME, "linux-wallpaperengine");
 

+ 284 - 67
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <array>
+#include <cstdio>
 #include <cstring>
 #include <iterator>
 #include <limits>
@@ -652,11 +653,10 @@ CImage::ResolvedTransform CImage::localTransform (const Object& object) {
 	scale = image->scale->value->getVec3 ();
 	angle = image->angles->value->getVec3 ().z;
 
-	// autosize computes this model's size/canvas from its actual content, but that canvas isn't
-	// necessarily centered on the content's real pivot (e.g. a puppet piece attached at the wrist,
-	// with the rest of its mesh extending away from center) - cropoffset is the correction for that,
-	// in the model's own local units, applied the same way the rest of "origin" gets scaled below.
-	if (image->model->cropOffset.has_value ()) {
+	// cropoffset corrects autosize's canvas centering against the content's real pivot. Only
+	// applies to puppets - non-puppet autosized images already have it baked into their own
+	// "origin" by the editor, so applying it again doubles the shift.
+	if (image->model->cropOffset.has_value () && image->model->puppet.has_value ()) {
 	    origin.x += image->model->cropOffset->x;
 	    origin.y += image->model->cropOffset->y;
 	}
@@ -708,22 +708,40 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	// exactly the point on screen the attachment bone currently sits at, which becomes this object's
 	// anchor instead of the parent's own origin. The object's own "origin" is then a small local nudge
 	// around that anchor, scaled the same way a normal child's origin would be.
+	//
+	// This mirrors the real engine's attachment resolution: a full matrix multiply of the parent's
+	// world transform with the bone's local one, so both the parent's and the bone's current rotation
+	// need to carry into the anchor. The bone's angle is negated alongside its Y-flipped position to
+	// stay consistent (mirroring an axis negates a rotation).
 	glm::vec3 anchorOrigin = resolved.origin;
+	float anchorAngle = resolved.angle;
+	glm::vec2 anchorScale = { 1.0f, 1.0f };
 	if (chain[i]->attachment.has_value () && chain[i]->parent.has_value ()) {
 	    const auto* parentCObject = this->getScene ().getObject (chain[i]->parent.value ());
 	    if (const auto* parentImage = dynamic_cast<const CImage*> (parentCObject); parentImage != nullptr) {
-		if (const auto meshPosition = parentImage->getAttachmentPointMeshPosition (*chain[i]->attachment);
-		    meshPosition.has_value ()) {
-		    anchorOrigin.x = resolved.origin.x + meshPosition->x * resolved.scale.x;
-		    anchorOrigin.y = resolved.origin.y - meshPosition->y * resolved.scale.y;
+		if (const auto meshTransform = parentImage->getAttachmentPointMeshTransform (*chain[i]->attachment);
+		    meshTransform.has_value ()) {
+		    const glm::vec2 meshOffset = rotateVec2 (
+			{ meshTransform->position.x * resolved.scale.x, -meshTransform->position.y * resolved.scale.y },
+			resolved.angle
+		    );
+		    anchorOrigin.x = resolved.origin.x + meshOffset.x;
+		    anchorOrigin.y = resolved.origin.y + meshOffset.y;
+		    anchorAngle = resolved.angle - meshTransform->angle;
+		    // the bone's own scale (possibly negative, i.e. a mirrored bone) carries into whatever
+		    // rides it, same as position/rotation
+		    anchorScale = meshTransform->scale;
 
 		    if (!this->m_attachmentDiagnosticLogged.contains (chain[i]->id)) {
 			this->m_attachmentDiagnosticLogged.insert (chain[i]->id);
 			sLog.out (
 			    "Attachment resolve for ", chain[i]->name, " (", chain[i]->id, "): point=",
-			    *chain[i]->attachment, " meshPosition=(", meshPosition->x, ",", meshPosition->y,
-			    ") parentOrigin=(", resolved.origin.x, ",", resolved.origin.y, ") parentScale=",
-			    resolved.scale.x, " anchorOrigin=(", anchorOrigin.x, ",", anchorOrigin.y, ")"
+			    *chain[i]->attachment, " meshPosition=(", meshTransform->position.x, ",",
+			    meshTransform->position.y, ") boneAngleDeg=", glm::degrees (meshTransform->angle),
+			    " boneScale=(", meshTransform->scale.x, ",", meshTransform->scale.y, ") parentOrigin=(",
+			    resolved.origin.x, ",", resolved.origin.y, ") parentScale=", resolved.scale.x,
+			    " anchorOrigin=(", anchorOrigin.x, ",", anchorOrigin.y, ") anchorAngleDeg=",
+			    glm::degrees (anchorAngle)
 			);
 		    }
 		}
@@ -731,11 +749,22 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	}
 
 	const glm::vec2 offset
-	    = rotateVec2 ({ local.origin.x * resolved.scale.x, local.origin.y * resolved.scale.y }, resolved.angle);
+	    = rotateVec2 ({ local.origin.x * resolved.scale.x, local.origin.y * resolved.scale.y }, anchorAngle);
 	local.origin.x = anchorOrigin.x + offset.x;
 	local.origin.y = anchorOrigin.y + offset.y;
 	local.origin.z = resolved.origin.z + local.origin.z * resolved.scale.z;
-	resolved = { local.origin, local.scale * resolved.scale, local.angle + resolved.angle };
+	local.scale.x *= anchorScale.x;
+	local.scale.y *= anchorScale.y;
+	resolved = { local.origin, local.scale * resolved.scale, local.angle + anchorAngle };
+
+	if ((chain[i]->id == 422 || chain[i]->id == 134) && !this->m_finalOriginLogged.contains (chain[i]->id)) {
+	    this->m_finalOriginLogged.insert (chain[i]->id);
+	    sLog.out (
+		"TEMP-DIAG final resolved origin for ", chain[i]->name, " (", chain[i]->id, "): anchorOrigin=(",
+		anchorOrigin.x, ",", anchorOrigin.y, ") offset=(", offset.x, ",", offset.y, ") finalOrigin=(",
+		resolved.origin.x, ",", resolved.origin.y, ")"
+	    );
+	}
     }
 
     return resolved;
@@ -1115,14 +1144,9 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		    );
 		}
 
-		// puppets typically declare several "additive" layers (idle sway, blinking, hand
-		// movement, ...), but they're not disjoint per-bone corrections - summing every active
-		// layer's rotation delta onto every bone compounds into serious over-rotation wherever
-		// more than one layer touches the same bone (confirmed against real clip data: individual
-		// layers swing a single bone up to ~19 degrees, and most bones get non-trivial rotation
-		// from several layers at once). Without knowing Wallpaper Engine's real blend weighting for
-		// simultaneous additive layers, playing just the first matching one is the safe choice - it
-		// matches what every previously-working puppet in this codebase actually needed.
+		// puppets can declare several simultaneous "additive" layers (idle sway, blinking, hand
+		// movement, ...) - collect every matching one here; updatePuppetSkinning blend-weights
+		// them together per bone using each layer's own "blend" setting.
 		for (const auto& layer : this->getImage ().animationLayers) {
 		    auto match = std::find_if (clips.begin (), clips.end (), [&layer] (const PuppetAnimationClip& clip) {
 			return clip.name == layer->name;
@@ -1134,7 +1158,6 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		    this->m_puppetActiveAnimations.push_back (
 			PuppetActiveAnimation { .clip = std::move (*match), .layer = layer.get () }
 		    );
-		    break;
 		}
 
 		if (this->m_puppetActiveAnimations.empty () && !clips.empty () && !this->getImage ().animationLayers.empty ()) {
@@ -1213,18 +1236,24 @@ void CImage::updatePuppetPositionBuffer (const glm::vec2& size) {
 	const float localX = size.x / 2.0f + source[index];
 	const float localY = size.y / 2.0f - source[index + 1];
 	if (bakeScenePosition) {
-	    // same origin+scale formula resolveTransform() already uses to place attachment-point
-	    // children, just applied directly to every vertex instead of to a single child object
+	    // maps the local-canvas coordinate onto this object's scene-space bounding box; m_pos.w is
+	    // its bottom edge (m_pos.y is the top, see updateScenePosition()) so localY==0 has to land
+	    // there, not on m_pos.y, or the puppet renders vertically flipped
 	    positions.push_back (this->m_pos.x + localX * this->m_puppetScale.x);
-	    positions.push_back (this->m_pos.y - localY * this->m_puppetScale.y);
+	    positions.push_back (this->m_pos.w + localY * this->m_puppetScale.y);
 	} else {
 	    positions.push_back (localX);
 	    positions.push_back (localY);
 	}
-	positions.push_back (source[index + 2]);
+	// raw .mdl Z values aren't used by this engine's orthographic puppet compositing (depth test
+	// is disabled for puppets; layering comes from draw order + alpha blending) - and glm::ortho's
+	// clip.z = -localZ has no near/far normalization, so a puppet's real mesh depth (tens of units)
+	// would get clipped outside [-1,1] and lose most of the mesh. Zero it instead.
+	positions.push_back (0.0f);
     }
 
-    if (!this->m_puppetPositionDiagnosticLogged) {
+    // skip the constructor's pre-setup() call, where m_passes/m_pos/m_puppetScale aren't resolved yet
+    if (!this->m_puppetPositionDiagnosticLogged && !this->m_passes.empty ()) {
 	this->m_puppetPositionDiagnosticLogged = true;
 	glm::vec3 boundsMin (std::numeric_limits<float>::max ());
 	glm::vec3 boundsMax (std::numeric_limits<float>::lowest ());
@@ -1261,41 +1290,47 @@ void CImage::updatePuppetSkinning () {
 	return;
     }
 
-    // Only one clip actually plays at a time (see the loop that fills m_puppetActiveAnimations), so
-    // each bone's local transform is just that clip's own current sample, used directly - NOT the
-    // bone's bindLocal from the MDLS array. Those two are usually near-identical (a clip's frame 0
-    // matches bind pose), but not always: some files carry bones whose MDLS bindLocal translation is
-    // wildly different from what every one of that bone's own animation samples says (seen firsthand -
-    // thousands of units off, on bones driving a hair strand that should sit right next to the body).
-    // Falling back to bindLocal in that case visibly detaches whatever that bone drives.
-    const PuppetActiveAnimation* active = nullptr;
+    // every matching, currently-visible animation layer plays and blends by its own "blend" weight,
+    // instead of only the first one. bindLocal from the MDLS array is deliberately not used as a
+    // rotation baseline - a clip's own sample is used directly, since some files carry bones whose
+    // MDLS bindLocal translation is wildly different from what their animation samples say, and
+    // falling back to it visibly detaches whatever that bone drives.
+    struct ActiveLayerSample {
+	const PuppetAnimationClip* clip;
+	uint32_t frame0;
+	uint32_t frame1;
+	float alpha;
+	float blend;
+    };
+
+    std::vector<ActiveLayerSample> samples;
     for (const auto& candidate : this->m_puppetActiveAnimations) {
-	if (candidate.layer != nullptr && candidate.layer->visible->value->getBool ()) {
-	    active = &candidate;
-	    break;
+	if (candidate.layer == nullptr || !candidate.layer->visible->value->getBool ()) {
+	    continue;
 	}
-    }
 
-    if (active == nullptr) {
-	return;
-    }
+	const auto& clip = candidate.clip;
+	const float duration = clip.fps > 0.0f ? static_cast<float> (clip.frameCount) / clip.fps : 0.0f;
+	const float rate = candidate.layer->rate->value->getFloat ();
 
-    const auto& clip = active->clip;
-    const float duration = clip.fps > 0.0f ? static_cast<float> (clip.frameCount) / clip.fps : 0.0f;
-    const float rate = active->layer->rate->value->getFloat ();
-
-    float frameFloat = 0.0f;
-    if (duration > 0.0f) {
-	float elapsed = std::fmod (g_Time * rate, duration);
-	if (elapsed < 0.0f) {
-	    elapsed += duration;
+	float frameFloat = 0.0f;
+	if (duration > 0.0f) {
+	    float elapsed = std::fmod (g_Time * rate, duration);
+	    if (elapsed < 0.0f) {
+		elapsed += duration;
+	    }
+	    frameFloat = elapsed * clip.fps;
 	}
-	frameFloat = elapsed * clip.fps;
+
+	const auto frame0 = std::min (static_cast<uint32_t> (frameFloat), clip.frameCount);
+	samples.push_back (ActiveLayerSample {
+	    .clip = &clip, .frame0 = frame0, .frame1 = std::min (frame0 + 1, clip.frameCount),
+	    .alpha = frameFloat - static_cast<float> (frame0), .blend = candidate.layer->blend->value->getFloat () });
     }
 
-    const auto frame0 = std::min (static_cast<uint32_t> (frameFloat), clip.frameCount);
-    const auto frame1 = std::min (frame0 + 1, clip.frameCount);
-    const float alpha = frameFloat - static_cast<float> (frame0);
+    if (samples.empty ()) {
+	return;
+    }
 
     std::vector<int> animatedParents (this->m_puppetBones.size ());
     std::vector<glm::mat4> animatedLocals (this->m_puppetBones.size ());
@@ -1304,15 +1339,28 @@ void CImage::updatePuppetSkinning () {
 	const auto& bone = this->m_puppetBones[i];
 	animatedParents[i] = bone.parent;
 
-	glm::vec3 position (bone.bindLocal[3]);
+	const glm::vec3 bindPosition (bone.bindLocal[3]);
+	glm::vec3 position = bindPosition;
 	glm::vec3 rotation (0.0f);
 	glm::vec3 scale (1.0f);
+	bool anyTrack = false;
 
-	if (i < clip.boneTracks.size () && clip.boneTracks[i].size () > frame1) {
-	    const auto& track = clip.boneTracks[i];
-	    position = lerp (track[frame0].position, track[frame1].position, alpha);
-	    rotation = lerp (track[frame0].rotation, track[frame1].rotation, alpha);
-	    scale = lerp (track[frame0].scale, track[frame1].scale, alpha);
+	// each layer contributes a blend-weighted delta from the shared baseline (bind position, zero
+	// rotation, unit scale) rather than replacing it outright
+	for (const auto& sample : samples) {
+	    if (i >= sample.clip->boneTracks.size () || sample.clip->boneTracks[i].size () <= sample.frame1) {
+		continue;
+	    }
+
+	    anyTrack = true;
+	    const auto& track = sample.clip->boneTracks[i];
+	    const glm::vec3 trackPosition = lerp (track[sample.frame0].position, track[sample.frame1].position, sample.alpha);
+	    const glm::vec3 trackRotation = lerp (track[sample.frame0].rotation, track[sample.frame1].rotation, sample.alpha);
+	    const glm::vec3 trackScale = lerp (track[sample.frame0].scale, track[sample.frame1].scale, sample.alpha);
+
+	    position += sample.blend * (trackPosition - bindPosition);
+	    rotation += sample.blend * trackRotation;
+	    scale += sample.blend * (trackScale - glm::vec3 (1.0f));
 	}
 
 	glm::mat4 local = glm::translate (glm::mat4 (1.0f), position);
@@ -1322,11 +1370,23 @@ void CImage::updatePuppetSkinning () {
 	local = glm::scale (local, scale);
 
 	animatedLocals[i] = local;
+
+	if (!this->m_boneTrackDiagLogged) {
+	    sLog.out (
+		"TEMP-DIAG bone anim for ", this->getImage ().name, " (", this->getId (), ") i=", i, " parent=",
+		bone.parent, " bindLocalPos=(", bone.bindLocal[3].x, ",", bone.bindLocal[3].y, ") animatedPos=(",
+		position.x, ",", position.y, ",", position.z, ") rotationDeg=(", glm::degrees (rotation.x), ",",
+		glm::degrees (rotation.y), ",", glm::degrees (rotation.z), ") scale=(", scale.x, ",", scale.y, ",",
+		scale.z, ") hasTrack=", anyTrack, " activeLayers=", samples.size ()
+	    );
+	}
     }
 
+    this->m_boneTrackDiagLogged = true;
+
     const std::vector<glm::mat4> worldAnimated = composeBoneWorldTransforms (animatedParents, animatedLocals);
 
-    // attachment points (see getAttachmentPointMeshPosition) need the live bone transforms independently
+    // 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;
 
@@ -1370,7 +1430,7 @@ void CImage::updatePuppetSkinning () {
     this->updatePuppetPositionBuffer (this->m_size);
 }
 
-std::optional<glm::vec3> CImage::getAttachmentPointMeshPosition (const std::string& name) const {
+std::optional<CImage::AttachmentPointTransform> CImage::getAttachmentPointMeshTransform (const std::string& name) const {
     if (this->m_puppetBoneWorldAnimated.empty ()) {
 	return std::nullopt;
     }
@@ -1386,7 +1446,19 @@ std::optional<glm::vec3> CImage::getAttachmentPointMeshPosition (const std::stri
 
     const glm::mat4 animatedWorld = this->m_puppetBoneWorldAnimated[it->boneIndex] * it->localTransform;
 
-    return glm::vec3 (animatedWorld[3]);
+    const float angle = std::atan2 (animatedWorld[0][1], animatedWorld[0][0]);
+
+    // scale.y = det(X,Y)/scale.x, projecting the transformed Y-basis onto what an unreflected
+    // rotation by `angle` would have produced - comes out negative if the bone's matrix includes a
+    // reflection (mirrored bone), instead of folding that into a bogus rotation angle
+    const float scaleX = glm::length (glm::vec2 (animatedWorld[0]));
+    const glm::vec2 scale
+	= scaleX > 1e-6f ? glm::vec2 (
+	      scaleX, (animatedWorld[0][0] * animatedWorld[1][1] - animatedWorld[0][1] * animatedWorld[1][0]) / scaleX
+	  )
+			 : glm::vec2 (scaleX, glm::length (glm::vec2 (animatedWorld[1])));
+
+    return AttachmentPointTransform { .position = glm::vec3 (animatedWorld[3]), .angle = angle, .scale = scale };
 }
 
 void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
@@ -1425,7 +1497,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] () {
+	[this, pass] () {
 	    GLint currentFramebuffer = 0;
 	    glGetIntegerv (GL_DRAW_FRAMEBUFFER_BINDING, &currentFramebuffer);
 	    if (currentFramebuffer != static_cast<GLint> (this->getScene ().getFBO ()->getFramebuffer ())) {
@@ -1437,9 +1509,76 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		    previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]
 		);
 	    }
+
+	    if (this->getId () == 418) {
+		static bool uniformChecked = false;
+		if (!uniformChecked) {
+		    uniformChecked = true;
+		    const GLint loc = glGetUniformLocation (pass->getProgramID (), "g_ModelViewProjectionMatrix");
+		    GLfloat uniformVals[16] = {};
+		    if (loc >= 0) {
+			glGetUniformfv (pass->getProgramID (), loc, uniformVals);
+		    }
+		    sLog.out (
+			"TEMP-DIAG uniform check for koshinibody: programID=", pass->getProgramID (),
+			" mvpLocation=", loc, " uniformCol0=(", uniformVals[0], ",", uniformVals[1], ",",
+			uniformVals[2], ",", uniformVals[3], ") uniformCol3=(", uniformVals[12], ",", uniformVals[13],
+			",", uniformVals[14], ",", uniformVals[15], ")"
+		    );
+		}
+	    }
+
 	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
 	    glDrawElements (GL_TRIANGLES, this->m_puppetIndexCount, GL_UNSIGNED_SHORT, nullptr);
 
+	    {
+		static int liveDiagCounter = 0;
+		if (this->getId () == 418 && (liveDiagCounter++ % 25 == 0)) {
+		    GLfloat liveBuf[9] = {};
+		    glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+		    glGetBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (liveBuf), liveBuf);
+		    const glm::mat4& mvp = this->m_modelViewProjectionScreen;
+		    const glm::vec4 c0 = mvp * glm::vec4 (liveBuf[0], liveBuf[1], liveBuf[2], 1.0f);
+		    const glm::vec4 c1 = mvp * glm::vec4 (liveBuf[3], liveBuf[4], liveBuf[5], 1.0f);
+		    const glm::vec4 c2 = mvp * glm::vec4 (liveBuf[6], liveBuf[7], liveBuf[8], 1.0f);
+		    sLog.out (
+			"TEMP-DIAG live-ndc frame=", liveDiagCounter, " for ", this->getImage ().name, " (",
+			this->getId (), "): buf=[", liveBuf[0], " ", liveBuf[1], " ", liveBuf[2], "|", liveBuf[3], " ",
+			liveBuf[4], " ", liveBuf[5], "|", liveBuf[6], " ", liveBuf[7], " ", liveBuf[8], "] ndc0=(",
+			c0.x / c0.w, ",", c0.y / c0.w, ") ndc1=(", c1.x / c1.w, ",", c1.y / c1.w, ") ndc2=(",
+			c2.x / c2.w, ",", c2.y / c2.w, ")"
+		    );
+		}
+	    }
+
+	    {
+		static int dumpCounter = 0;
+		if (this->getId () == 418 && dumpCounter++ == 100) {
+		    const glm::mat4& copyProj = this->m_modelViewProjectionCopy;
+		    sLog.out (
+			"TEMP-DIAG modelViewProjectionCopy for koshinibody: col0=(", copyProj[0][0], ",", copyProj[0][1],
+			",", copyProj[0][2], ",", copyProj[0][3], ") col1=(", copyProj[1][0], ",", copyProj[1][1], ",",
+			copyProj[1][2], ",", copyProj[1][3], ") col3=(", copyProj[3][0], ",", copyProj[3][1], ",",
+			copyProj[3][2], ",", copyProj[3][3], ") m_size=(", this->m_size.x, ",", this->m_size.y, ")"
+		    );
+		    GLint vp[4] = {};
+		    glGetIntegerv (GL_VIEWPORT, vp);
+		    const int w = vp[2], h = vp[3];
+		    if (w > 0 && h > 0 && w < 8192 && h < 8192) {
+			std::vector<unsigned char> pixels (static_cast<size_t> (w) * h * 4);
+			glReadPixels (0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data ());
+			FILE* f = fopen ("/tmp/koshini_fbo_dump.raw", "wb");
+			if (f) {
+			    fwrite (&w, sizeof (int), 1, f);
+			    fwrite (&h, sizeof (int), 1, f);
+			    fwrite (pixels.data (), 1, pixels.size (), f);
+			    fclose (f);
+			    sLog.out ("TEMP-DIAG dumped FBO contents for koshinibody: ", w, "x", h, " to /tmp/koshini_fbo_dump.raw");
+			}
+		    }
+		}
+	    }
+
 	    if (!this->m_puppetDrawErrorChecked) {
 		this->m_puppetDrawErrorChecked = true;
 		GLint boundFBO = 0;
@@ -1450,6 +1589,15 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		glActiveTexture (GL_TEXTURE0);
 		glGetIntegerv (GL_TEXTURE_BINDING_2D, &boundTexture);
 		const GLenum err = glGetError ();
+		const GLboolean cullEnabled = glIsEnabled (GL_CULL_FACE);
+		const GLboolean depthEnabled = glIsEnabled (GL_DEPTH_TEST);
+		const GLboolean scissorEnabled = glIsEnabled (GL_SCISSOR_TEST);
+		const GLboolean blendEnabled = glIsEnabled (GL_BLEND);
+		GLint cullFaceMode = 0, frontFace = 0;
+		glGetIntegerv (GL_CULL_FACE_MODE, &cullFaceMode);
+		glGetIntegerv (GL_FRONT_FACE, &frontFace);
+		GLboolean colorMask[4] = {};
+		glGetBooleanv (GL_COLOR_WRITEMASK, colorMask);
 		sLog.out (
 		    "Puppet draw result for ", this->getImage ().name, " (", this->getId (), "): glError=", err,
 		    " boundFBO=", boundFBO, " sceneFBO=", this->getScene ().getFBO ()->getFramebuffer (), " viewport=(",
@@ -1457,7 +1605,47 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		    " ownTextureReady=", (this->getTexture () != nullptr && this->getTexture ()->isReady ()), " ownTextureID=",
 		    (this->getTexture () != nullptr ? this->getTexture ()->getTextureID (0) : 0), " color4=(",
 		    this->getColor4 ().r, ",", this->getColor4 ().g, ",", this->getColor4 ().b, ",", this->getColor4 ().a,
-		    ") alpha=", this->getUserAlpha (), " brightness=", this->getBrightness ()
+		    ") alpha=", this->getUserAlpha (), " brightness=", this->getBrightness (), " cullEnabled=",
+		    (int) cullEnabled, " cullFaceMode=", cullFaceMode, " frontFace=", frontFace, " depthEnabled=",
+		    (int) depthEnabled, " scissorEnabled=", (int) scissorEnabled, " blendEnabled=", (int) blendEnabled,
+		    " colorMask=(", (int) colorMask[0], ",", (int) colorMask[1], ",", (int) colorMask[2], ",",
+		    (int) colorMask[3], ")"
+		);
+
+		const size_t vertexCount = this->m_puppetRawPositions.size () / 3;
+		std::vector<GLushort> idx (this->m_puppetIndexCount);
+		glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
+		glGetBufferSubData (GL_ELEMENT_ARRAY_BUFFER, 0, idx.size () * sizeof (GLushort), idx.data ());
+
+		std::vector<GLfloat> pos (vertexCount * 3);
+		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+		glGetBufferSubData (GL_ARRAY_BUFFER, 0, pos.size () * sizeof (GLfloat), pos.data ());
+
+		double totalArea = 0.0, minArea = 1e30, maxArea = 0.0;
+		size_t degenerate = 0, outOfRange = 0;
+		for (size_t t = 0; t + 2 < idx.size (); t += 3) {
+		    const GLushort ia = idx[t], ib = idx[t + 1], ic = idx[t + 2];
+		    if (ia >= vertexCount || ib >= vertexCount || ic >= vertexCount) {
+			outOfRange++;
+			continue;
+		    }
+		    const glm::vec3 a (pos[ia * 3], pos[ia * 3 + 1], pos[ia * 3 + 2]);
+		    const glm::vec3 b (pos[ib * 3], pos[ib * 3 + 1], pos[ib * 3 + 2]);
+		    const glm::vec3 c (pos[ic * 3], pos[ic * 3 + 1], pos[ic * 3 + 2]);
+		    const double area = 0.5 * glm::length (glm::cross (b - a, c - a));
+		    if (area < 1e-6) {
+			degenerate++;
+		    }
+		    totalArea += area;
+		    minArea = std::min (minArea, area);
+		    maxArea = std::max (maxArea, area);
+		}
+		const size_t triCount = idx.size () / 3;
+		sLog.out (
+		    "TEMP-DIAG mesh coherence for ", this->getImage ().name, " (", this->getId (), "): vertexCount=",
+		    vertexCount, " triCount=", triCount, " degenerate=", degenerate, " outOfRange=", outOfRange,
+		    " minArea=", minArea, " maxArea=", maxArea, " avgArea=", (triCount > 0 ? totalArea / triCount : 0.0),
+		    " totalArea=", totalArea
 		);
 	    }
 	},
@@ -1710,6 +1898,35 @@ void CImage::setupPasses () {
 	    spacePosition = this->getSceneSpacePosition ();
 	    projection = &this->m_modelViewProjectionScreen;
 	    inverseProjection = &this->m_modelViewProjectionScreenInverse;
+
+	    if (this->m_hasPuppetMesh) {
+		GLfloat bufDump[18] = {};
+		glBindBuffer (GL_ARRAY_BUFFER, spacePosition);
+		glGetBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (bufDump), bufDump);
+		sLog.out (
+		    "TEMP-DIAG final-pass-branch for ", this->getImage ().name, " (", this->getId (),
+		    "): isFirstPass=", isFirstPass, " m_pos=(", this->m_pos.x, ",", this->m_pos.y, ",", this->m_pos.z,
+		    ",", this->m_pos.w, ") spacePosition=", spacePosition, " sceneSpacePositionBuffer=",
+		    this->getSceneSpacePosition (), " passCount=", this->m_passes.size (), " bufVerts=[",
+		    bufDump[0], " ", bufDump[1], " ", bufDump[2], " | ", bufDump[3], " ", bufDump[4], " ", bufDump[5],
+		    " | ", bufDump[6], " ", bufDump[7], " ", bufDump[8], " | ", bufDump[9], " ", bufDump[10], " ",
+		    bufDump[11], " | ", bufDump[12], " ", bufDump[13], " ", bufDump[14], " | ", bufDump[15], " ",
+		    bufDump[16], " ", bufDump[17], "]"
+		);
+
+		const glm::mat4& mvp = *projection;
+		const glm::vec4 c0 = mvp * glm::vec4 (bufDump[0], bufDump[1], bufDump[2], 1.0f);
+		const glm::vec4 c1 = mvp * glm::vec4 (bufDump[3], bufDump[4], bufDump[5], 1.0f);
+		const glm::vec4 c2 = mvp * glm::vec4 (bufDump[6], bufDump[7], bufDump[8], 1.0f);
+		sLog.out (
+		    "TEMP-DIAG projection for ", this->getImage ().name, " (", this->getId (), "): mvpRow0=(", mvp[0][0],
+		    ",", mvp[1][0], ",", mvp[2][0], ",", mvp[3][0], ") mvpRow1=(", mvp[0][1], ",", mvp[1][1], ",",
+		    mvp[2][1], ",", mvp[3][1], ") clip0=(", c0.x, ",", c0.y, ",", c0.z, ",", c0.w, ") ndc0=(",
+		    c0.x / c0.w, ",", c0.y / c0.w, ") clip1=(", c1.x, ",", c1.y, ",", c1.z, ",", c1.w, ") ndc1=(",
+		    c1.x / c1.w, ",", c1.y / c1.w, ") clip2=(", c2.x, ",", c2.y, ",", c2.z, ",", c2.w, ") ndc2=(",
+		    c2.x / c2.w, ",", c2.y / c2.w, ")"
+		);
+	    }
 	}
 
 	pass->setDestination (drawTo);

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

@@ -95,13 +95,20 @@ public:
 
     void pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput);
 
+    /** A puppet attachment point's current animated position, rotation and scale, in this puppet's own
+     * local mesh space (same space as PuppetAttachmentPoint's position/localTransform). A negative
+     * scale component means the bone's transform includes a reflection (a mirrored bone). */
+    struct AttachmentPointTransform {
+	glm::vec3 position;
+	float angle;
+	glm::vec2 scale;
+    };
+
     /**
      * @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)
+     * @return The point's current animated transform, or nullopt if there's no such point (or no puppet mesh)
      */
-    [[nodiscard]] std::optional<glm::vec3> getAttachmentPointMeshPosition (const std::string& name) const;
+    [[nodiscard]] std::optional<AttachmentPointTransform> getAttachmentPointMeshTransform (const std::string& name) const;
 
 protected:
     void setupPasses ();
@@ -156,6 +163,8 @@ private:
     bool m_puppetPositionDiagnosticLogged = false;
     bool m_transformDiagnosticLogged = false;
     mutable std::set<int> m_attachmentDiagnosticLogged = {};
+    mutable std::set<int> m_finalOriginLogged = {};
+    bool m_boneTrackDiagLogged = false;
     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

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

@@ -897,6 +897,9 @@ CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceArou
 	    speedOverride] (ParticleInstance& p) mutable {
 	int controlPoint = static_cast<int> (controlPointValue->getFloat ());
 	int count = static_cast<int> (countValue->getFloat ());
+	if (count < 1) {
+	    count = 1;
+	}
 
 	float angle = (static_cast<float> (sequenceIndex) / static_cast<float> (count)) * glm::two_pi<float> ();
 	sequenceIndex = (sequenceIndex + 1) % count;

+ 27 - 0
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -199,6 +199,22 @@ const Material& compositeMaterial () {
     static const MaterialUniquePtr material = buildCompositeMaterial ();
     return *material;
 }
+
+// Mirrors CImage.cpp's clampParallaxAxis: keeps an edge pair from sliding past the viewport
+// once `offset` is added to both, freezing movement at 0 if the box is too small to fully
+// cover the viewport on this axis to begin with.
+float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExtent) {
+    const float low = std::min (edgeA, edgeB);
+    const float high = std::max (edgeA, edgeB);
+    const float half = sceneExtent / 2.0f;
+    const float maxOffset = -half - low;
+    const float minOffset = half - high;
+
+    if (minOffset > maxOffset)
+	return 0.0f;
+
+    return std::clamp (offset, minOffset, maxOffset);
+}
 } // namespace
 
 CText::CText (Wallpapers::CScene& scene, const Text& text) :
@@ -721,6 +737,17 @@ void CText::render () {
 	const float referenceSize = static_cast<float> (this->getScene ().getWidth ());
 	parallaxOffset.x = (depth.x + parallaxAmount) * displacement->x * referenceSize;
 	parallaxOffset.y = (depth.y + parallaxAmount) * displacement->y * referenceSize;
+
+	// mirrors CImage's parallax clamp, or a text layer drifts past its edges while a same-depth
+	// CImage backing panel freezes, visibly separating the two
+	if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.clampParallaxToImageSize) {
+	    const float baseX = origin.x + offsetX - scene_w * 0.5f;
+	    const float baseY = scene_h * 0.5f - (origin.y + offsetY);
+	    parallaxOffset.x
+		= clampParallaxAxis (parallaxOffset.x, baseX - scaledHalfWidth, baseX + scaledHalfWidth, scene_w);
+	    parallaxOffset.y
+		= clampParallaxAxis (parallaxOffset.y, baseY - scaledHalfHeight, baseY + scaledHalfHeight, scene_h);
+	}
     }
 
     const glm::vec3 gl_origin = {

+ 58 - 2
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -437,6 +437,61 @@ std::string ShaderUnit::applyFragmentTexCoordCompatibility (std::string source)
     return source;
 }
 
+std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string source) const {
+    if (this->m_type != GLSLContext::UnitType_Fragment) {
+	return source;
+    }
+
+    static const std::regex varyingDecl (R"(\bvarying\s+(vec[234]|float)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;)");
+
+    std::vector<std::pair<std::string, std::string>> shadowed;
+
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), varyingDecl); it != std::sregex_iterator ();
+	 ++it) {
+	const std::string type = (*it)[1].str ();
+	const std::string name = (*it)[2].str ();
+
+	// only shadow varyings the shader actually reassigns - a plain read-only "in" is fine as-is,
+	// and touching the declaration unnecessarily risks breaking a shader that works today
+	const std::regex assignmentUse (
+	    "\\b" + name + "\\b(?:\\.[xyzwrgba]+)?\\s*(?:=(?!=)|\\+=|-=|\\*=|/=)"
+	);
+	if (!std::regex_search (source, assignmentUse)) {
+	    continue;
+	}
+
+	shadowed.emplace_back (type, name);
+    }
+
+    if (shadowed.empty ()) {
+	return source;
+    }
+
+    static const std::regex mainOpen (R"(\bvoid\s+main\s*\([^)]*\)\s*\{)");
+    std::smatch mainMatch;
+    if (!std::regex_search (source, mainMatch, mainOpen)) {
+	return source;
+    }
+
+    // shadows each with a same-named local, initialized from the real (read-only) input, so the
+    // rest of main() can keep mutating it exactly like the original compatibility-profile shader did
+    std::string shadowCode;
+    for (const auto& [type, name] : shadowed) {
+	shadowCode += " " + type + " wpeShadowIn_" + name + " = " + name + "; " + type + " " + name + " = wpeShadowIn_" + name + ";";
+    }
+
+    const size_t insertAt = mainMatch.position (0) + mainMatch.length (0);
+    source.insert (insertAt, shadowCode);
+
+    std::string names;
+    for (const auto& [type, name] : shadowed) {
+	names += (names.empty () ? "" : ", ") + name;
+    }
+    sLog.out ("Applied fragment varying shadow compatibility in ", this->m_file, " for ", names);
+
+    return source;
+}
+
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
     // TODO: SUPPORT REQUIRES SO WE PROPERLY FOLLOW THE REQUIRED CHAIN
     JSON data;
@@ -704,8 +759,9 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    this->m_final
-	+= this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed));
+    this->m_final += this->applyFragmentVaryingShadowCompatibility (
+	this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed))
+    );
 
     // actual GLSL compilation happens in the pass, which has the context this unit doesn't
     return this->m_final;

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

@@ -58,6 +58,10 @@ private:
     [[nodiscard]] std::string applyLinkedVaryingCompatibility (std::string source) const;
     /** Adjusts fragment shaders that use wide texture coordinates as vec2 values in Wallpaper Engine effects. */
     [[nodiscard]] std::string applyFragmentTexCoordCompatibility (std::string source) const;
+    /** Old-style shaders sometimes reassign a `varying` as scratch storage, which our `#define varying in`
+     *  (GLSL 330 core) turns into an l-value error since `in` is read-only. Shadows any varying that's
+     *  actually written to with a same-named local at the top of main(), copied from the true input. */
+    [[nodiscard]] std::string applyFragmentVaryingShadowCompatibility (std::string source) const;
 
     void parseComboConfiguration (const std::string& content, int defaultValue = 0);
     void parseParameterConfiguration (const std::string& type, const std::string& name, const std::string& content);

+ 62 - 1
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -11,6 +11,7 @@
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
+#include <algorithm>
 #include <ranges>
 
 extern float g_Time;
@@ -70,7 +71,19 @@ CScene::CScene (
 	this->createObject (*object);
     }
 
-    for (const auto& object : scene->objects) {
+    // sort by explicit sortorder where declared; falls back to array position (not id) so this is a
+    // no-op for the vast majority of wallpapers that never set it - the compositing passes below
+    // depend on array order for reasons beyond simple z-ordering, and an id-based default regressed
+    // several previously-correct wallpapers
+    std::vector<std::pair<const Object*, int>> objectsByPaintOrder;
+    objectsByPaintOrder.reserve (scene->objects.size ());
+    for (int index = 0; index < static_cast<int> (scene->objects.size ()); index++) {
+	const Object* object = scene->objects[index].get ();
+	objectsByPaintOrder.emplace_back (object, object->sortOrder.value_or (index));
+    }
+    std::ranges::stable_sort (objectsByPaintOrder, [] (const auto& a, const auto& b) { return a.second < b.second; });
+
+    for (const auto& [object, sortKey] : objectsByPaintOrder) {
 	this->addObjectToRenderOrder (*object);
     }
 
@@ -386,6 +399,54 @@ const CObject* CScene::getObject (int id) const {
     return object == this->m_objects.end () ? nullptr : object->second;
 }
 
+int CScene::getObjectIndex (const CObject* object) const {
+    const auto it = std::ranges::find (this->m_objectsByRenderOrder, object);
+
+    if (it == this->m_objectsByRenderOrder.end ()) {
+	return -1;
+    }
+
+    return static_cast<int> (std::distance (this->m_objectsByRenderOrder.begin (), it));
+}
+
+Render::CObject* CScene::createLayer (const std::string& imagePath) {
+    const int id = this->m_nextDynamicLayerId++;
+
+    // same minimal-object-JSON approach the constructor uses for the bloom layer, so this gets the
+    // same defaults a real scene.json image object would
+    const JSON layerJson = {
+	{ "id", id },
+	{ "name", "scriptlayer_" + std::to_string (id) },
+	{ "image", imagePath },
+	{ "visible", true },
+    };
+
+    auto objectData = ObjectParser::parse (layerJson, this->getScene ().project);
+    Render::CObject* renderObject = this->createObject (*objectData);
+
+    if (renderObject == nullptr) {
+	return nullptr;
+    }
+
+    this->m_dynamicObjectData.emplace_back (std::move (objectData));
+    this->m_objectsByRenderOrder.push_back (renderObject);
+
+    return renderObject;
+}
+
+void CScene::sortLayer (CObject* object, int index) {
+    const auto current = std::ranges::find (this->m_objectsByRenderOrder, object);
+
+    if (current == this->m_objectsByRenderOrder.end ()) {
+	return;
+    }
+
+    this->m_objectsByRenderOrder.erase (current);
+
+    const int clampedIndex = std::clamp (index, 0, static_cast<int> (this->m_objectsByRenderOrder.size ()));
+    this->m_objectsByRenderOrder.insert (this->m_objectsByRenderOrder.begin () + clampedIndex, object);
+}
+
 void CScene::setAudioPolicy (bool muted, std::optional<int> ambientVolume) {
     const std::optional<int> volume = muted ? std::optional<int> (0) : ambientVolume;
 

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

@@ -42,9 +42,17 @@ public:
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const CObject* getObject (int id) const;
+    [[nodiscard]] int getObjectIndex (const CObject* object) const;
 
     void setAudioPolicy (bool muted, std::optional<int> ambientVolume) override;
 
+    /** Creates a new image layer from a model json at runtime, appended to the render order. Backs
+     *  the scripting API's thisScene.createLayer(). Returns nullptr if the model couldn't be set up. */
+    Render::CObject* createLayer (const std::string& imagePath);
+
+    /** Moves an existing layer to the given render-order slot. Backs thisScene.sortLayer(). */
+    void sortLayer (CObject* object, int index);
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void updateMouse (const glm::ivec4& viewport);
@@ -63,6 +71,10 @@ private:
     std::map<int, CObject*> m_objects = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
     std::vector<DynamicValue*> m_scriptedValues = {};
+    // owns the synthesized model data backing createLayer()'d objects; must outlive the CObject
+    // built from it (same pattern as m_bloomObjectData)
+    std::vector<ObjectUniquePtr> m_dynamicObjectData = {};
+    int m_nextDynamicLayerId = 2000000000;
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionNormalized = {};

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

@@ -126,4 +126,15 @@ JSValue ScriptableObjectAdapter::instantiate (ScriptableObject& object) {
 
 JSValue ScriptableObjectAdapter::instantiate (DynamicValue& value) {
     throw std::runtime_error ("Cannot create a ScriptableObject instance from a DynamicValue");
+}
+
+WallpaperEngine::Scripting::ScriptableObject* ScriptableObjectAdapter::getObject (JSValueConst value) {
+    JSClassID classId = 0;
+    auto* container = static_cast<OpaqueScriptableObjectAdapter*> (JS_GetAnyOpaque (value, &classId));
+
+    if (container == nullptr || container->magic != SCRIPTABLE_OPAQUE_MAGIC) {
+	return nullptr;
+    }
+
+    return &container->object;
 }

+ 4 - 0
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h

@@ -10,6 +10,10 @@ public:
     JSValue instantiate (ScriptableObject& object) override;
     JSValue instantiate (Data::Model::DynamicValue& value) override;
 
+    /** Unwraps a JS value produced by instantiate(ScriptableObject&) back to its C++ object, or
+     *  nullptr if the value isn't one (wrong type, plain JS object, etc). */
+    static ScriptableObject* getObject (JSValueConst value);
+
 private:
     JSClassExoticMethods m_exoticMethods;
     std::string m_name;

+ 70 - 1
src/WallpaperEngine/Scripting/SceneObject.cpp

@@ -190,6 +190,64 @@ JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst
     return JS_EXCEPTION;
 }
 
+JSValue get_layer_index (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_NewInt32 (ctx, -1);
+    }
+
+    auto* container = get_opaque (this_val);
+    auto* layer = WallpaperEngine::Scripting::Adapters::ScriptableObjectAdapter::getObject (argv[0]);
+
+    if (layer == nullptr) {
+	return JS_NewInt32 (ctx, -1);
+    }
+
+    return JS_NewInt32 (ctx, container->getScene ().getObjectIndex (layer));
+}
+
+JSValue create_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1 || !JS_IsString (argv[0])) {
+	return JS_UNDEFINED;
+    }
+
+    auto* container = get_opaque (this_val);
+    const char* path = JS_ToCString (ctx, argv[0]);
+
+    if (path == nullptr) {
+	return JS_UNDEFINED;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, path); });
+
+    auto* object = container->getScene ().createLayer (path);
+
+    if (object == nullptr || !object->is<ScriptableObject> ()) {
+	return JS_UNDEFINED;
+    }
+
+    return container->getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
+}
+
+JSValue sort_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 2) {
+	return JS_UNDEFINED;
+    }
+
+    auto* container = get_opaque (this_val);
+    auto* layer = WallpaperEngine::Scripting::Adapters::ScriptableObjectAdapter::getObject (argv[0]);
+
+    if (layer == nullptr) {
+	return JS_UNDEFINED;
+    }
+
+    int index = 0;
+    JS_ToInt32 (ctx, &index, argv[1]);
+
+    container->getScene ().sortLayer (layer, index);
+
+    return JS_UNDEFINED;
+}
+
 JSValue scene_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
 
 SceneObject::SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
@@ -306,7 +364,18 @@ SceneObject::SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scen
 	this->m_engine.getContext (), this->m_instance, "getLayer",
 	JS_NewCFunction (this->m_engine.getContext (), get_layer, "getLayer", 1), JS_PROP_ENUMERABLE
     );
-    // TODO: ADD REST OF THE METHODS
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "getLayerIndex",
+	JS_NewCFunction (this->m_engine.getContext (), get_layer_index, "getLayerIndex", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "createLayer",
+	JS_NewCFunction (this->m_engine.getContext (), create_layer, "createLayer", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "sortLayer",
+	JS_NewCFunction (this->m_engine.getContext (), sort_layer, "sortLayer", 2), JS_PROP_ENUMERABLE
+    );
 }
 
 SceneObject::~SceneObject () { JS_FreeValue (this->m_engine.getContext (), this->m_instance); }

+ 1 - 0
src/WallpaperEngine/Scripting/SceneObject.h

@@ -11,6 +11,7 @@ public:
     SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene);
     ~SceneObject ();
 
+    Render::Wallpapers::CScene& getScene () { return m_scene; }
     const Render::Wallpapers::CScene& getScene () const { return m_scene; }
     JSValue getInstance () const { return m_instance; }
     ScriptEngine& getEngine () const { return m_engine; }

+ 35 - 0
tools/dbus_noop_shim.c

@@ -0,0 +1,35 @@
+// LD_PRELOAD shim for headless test environments with no D-Bus session bus (no dbus-daemon,
+// no /etc/machine-id, no permission to create one - e.g. a minimal container).
+//
+// The engine already treats a failed dbus_bus_get() as non-fatal in most places (returns
+// nullptr, callers null-check), but at least one call path (a periodic MPRIS "detect player"
+// retry) calls dbus_connection_send_with_reply_and_block() without checking for a null
+// connection first, which aborts the whole process instead of just skipping that feature.
+// This shim intercepts that one libdbus entry point: if the connection is NULL, return NULL
+// (which is exactly what the real function would do on failure) instead of hitting libdbus's
+// own internal assertion and calling abort().
+//
+// Build:   gcc -shared -fPIC -o dbus_noop_shim.so dbus_noop_shim.c -ldl
+// Use:     LD_PRELOAD=/path/to/dbus_noop_shim.so ./linux-wallpaperengine ...
+#define _GNU_SOURCE
+#include <dlfcn.h>
+#include <stdio.h>
+
+typedef struct DBusConnection DBusConnection;
+typedef struct DBusMessage DBusMessage;
+typedef struct DBusError DBusError;
+
+typedef DBusMessage *(*real_fn_t)(DBusConnection *, DBusMessage *, int, DBusError *);
+
+DBusMessage *dbus_connection_send_with_reply_and_block(DBusConnection *connection, DBusMessage *message,
+                                                          int timeout_milliseconds, DBusError *error) {
+    if (connection == NULL) {
+        fprintf(stderr, "[dbus_noop_shim] blocked send_with_reply_and_block on NULL connection\n");
+        return NULL;
+    }
+    static real_fn_t real = NULL;
+    if (!real) {
+        real = (real_fn_t) dlsym(RTLD_NEXT, "dbus_connection_send_with_reply_and_block");
+    }
+    return real(connection, message, timeout_milliseconds, error);
+}

+ 68 - 0
tools/decode_tex.py

@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Decodes a Wallpaper Engine .tex file's first mipmap into a plain image file.
+
+Only handles the common case: TEXV0005/TEXI0001 header, TEXB0003/TEXB0004 container,
+FreeImage-encoded payload (FIF_PNG in every sample seen so far) - i.e. the mipmap bytes
+are themselves a complete PNG/etc file, not raw pixel data. Doesn't touch mipmaps beyond
+the first, animated (TEXS*) textures, or DXT/BC-compressed formats.
+
+Usage:
+    decode_tex.py <path-to.tex> <output-image-path>
+
+Needs: pip install lz4 (only when the mipmap is LZ4-compressed, i.e. most of them).
+"""
+import struct
+import sys
+
+try:
+    import lz4.block
+except ImportError:
+    lz4 = None
+
+
+def decode(path, out_path):
+    f = open(path, 'rb')
+    magic1 = f.read(9)
+    assert magic1[:8] == b'TEXV0005', magic1
+    magic2 = f.read(9)
+    assert magic2[:8] == b'TEXI0001', magic2
+    fmt, flags, tex_w, tex_h, w, h = struct.unpack('<6I', f.read(24))
+    f.read(4)  # ignored trailing header field
+
+    container_magic = f.read(9)
+    image_count = struct.unpack('<I', f.read(4))[0]
+    fif = None
+    if container_magic[:8] in (b'TEXB0003', b'TEXB0004'):
+        fif = struct.unpack('<I', f.read(4))[0]
+        if container_magic[:8] == b'TEXB0004':
+            f.read(4)  # isVideoMp4 flag
+    print('container', container_magic[:8], 'fif', fif, 'imageCount', image_count, 'declaredSize', w, h)
+
+    mipmap_count = struct.unpack('<I', f.read(4))[0]
+    print('mipmapCount', mipmap_count)
+    mip_w, mip_h = struct.unpack('<II', f.read(8))
+    compression, uncompressed_size, compressed_size = struct.unpack('<Iii', f.read(12))
+    print('mip0', mip_w, mip_h, 'compression', compression,
+          'uncompressedSize', uncompressed_size, 'compressedSize', compressed_size)
+
+    if compression == 0:
+        # misnamed in the engine's own parser too: compressedSize holds the real length here
+        uncompressed_size = compressed_size
+        data = f.read(uncompressed_size)
+    elif compression == 1:
+        if lz4 is None:
+            raise SystemExit("mipmap is LZ4-compressed - install with: pip install lz4")
+        comp = f.read(compressed_size)
+        data = lz4.block.decompress(comp, uncompressed_size=uncompressed_size)
+    else:
+        raise SystemExit(f"unknown compression mode {compression}")
+
+    open(out_path, 'wb').write(data)
+    print('wrote', out_path, len(data), 'bytes, first bytes:', data[:8])
+
+
+if __name__ == '__main__':
+    if len(sys.argv) != 3:
+        print(__doc__)
+        raise SystemExit(1)
+    decode(sys.argv[1], sys.argv[2])

+ 70 - 0
tools/headless_render.sh

@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Renders a wallpaper headlessly (no real display/GPU needed - Xvfb + Mesa llvmpipe software
+# rendering) and takes a screenshot, for environments with no way to see the real screen
+# (sandboxes, CI, this project's own dev container). Confirmed working there; a real machine
+# with a GPU and a display doesn't need this, just run the binary normally.
+#
+# Usage:
+#   tools/headless_render.sh <path-to-linux-wallpaperengine-binary> <output.png> [engine args...]
+#
+# Example:
+#   tools/headless_render.sh build/output/linux-wallpaperengine /tmp/out.png \
+#       --assets-dir /path/to/wallpaper_engine/assets --window 0x0x1920x1080 --fps 25 \
+#       /path/to/workshop/item/dir
+#
+# Requirements: Xvfb, a software GL renderer (mesa llvmpipe is normally already present
+# alongside libgl1-mesa-dri), gcc (to build the D-Bus shim once, cached after that).
+#
+# Known limitations:
+# - No real D-Bus session bus is assumed - dbus_noop_shim.so (built automatically) patches the
+#   one call path that isn't already null-safe against a missing bus.
+# - --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.
+set -euo pipefail
+
+if [ "$#" -lt 2 ]; then
+    echo "Usage: $0 <binary> <output.png> [engine args...]" >&2
+    exit 1
+fi
+
+BINARY="$1"; shift
+OUTPUT="$1"; shift
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+BINARY_DIR="$(cd "$(dirname "$BINARY")" && pwd)"
+SHIM_SRC="$SCRIPT_DIR/dbus_noop_shim.c"
+SHIM_SO="/tmp/dbus_noop_shim.so"
+
+if [ ! -f "$SHIM_SO" ] || [ "$SHIM_SRC" -nt "$SHIM_SO" ]; then
+    gcc -shared -fPIC -o "$SHIM_SO" "$SHIM_SRC" -ldl
+fi
+
+XVFB_DISPLAY="${HEADLESS_RENDER_DISPLAY:-:99}"
+XVFB_SOCKET="/tmp/.X11-unix/X${XVFB_DISPLAY#:}"
+if [ ! -S "$XVFB_SOCKET" ]; then
+    Xvfb "$XVFB_DISPLAY" -screen 0 1920x1080x24 &
+    XVFB_PID=$!
+    trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT
+    # give it a moment to bind before launching anything against it
+    for _ in $(seq 1 20); do
+        [ -S "$XVFB_SOCKET" ] && break
+        sleep 0.2
+    done
+fi
+
+DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11 \
+LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
+LD_PRELOAD="$SHIM_SO" \
+timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \
+    --window 0x0x1920x1080 \
+    --screenshot "$OUTPUT" \
+    --screenshot-delay "${HEADLESS_RENDER_DELAY:-3}" \
+    "$@"
+
+if [ -f "$OUTPUT" ]; then
+    echo "Wrote $OUTPUT"
+else
+    echo "No screenshot was produced - check the engine's stderr output above" >&2
+    exit 1
+fi

+ 72 - 0
tools/mdl_bones.py

@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""Dumps a puppet .mdl file's MDLS bone array: parent index, bind-pose matrix determinant
+(to spot a mirrored/negative-scale bone), and translation - without needing a debug build.
+
+Mirrors the parsing in CImage.cpp's parsePuppetBones()/readPuppetMeshData(): MDLS header is
+9 bytes, then nextSectionOffset(u32) + boneCount(u32), then per bone: 1 padding byte + type(u32)
++ parent(i32) + matrixBytes(u32) + [16 floats if matrixBytes==64] + null-terminated name. The
+16 floats are the file's row-major matrix - reshaping them row-major (not glm's column-major
+convention) gives the actual authored matrix back, which is what this script does.
+
+Usage:
+    mdl_bones.py <path-to-puppet.mdl>
+
+Needs: pip install numpy
+"""
+import struct
+import sys
+
+import numpy as np
+
+
+def read_cstr(data, offset):
+    end = data.index(b'\x00', offset)
+    return data[offset:end].decode('latin1', errors='replace'), end + 1
+
+
+def main(path):
+    data = open(path, 'rb').read()
+    marker_size = 9
+    mdls_offset = data.find(b'MDLS', marker_size)
+    if mdls_offset < 0:
+        raise SystemExit("no MDLS section found - not a puppet mesh, or a layout this script doesn't handle")
+    print('MDLS at', mdls_offset, 'file size', len(data))
+
+    offset = mdls_offset + 9  # skip the MDLS0001-style 9-byte magic
+    next_section_offset = struct.unpack_from('<I', data, offset)[0]
+    offset += 4
+    bone_count = struct.unpack_from('<I', data, offset)[0]
+    offset += 4
+    print('nextSectionOffset', next_section_offset, 'boneCount', bone_count)
+
+    for i in range(bone_count):
+        offset += 1  # padding byte
+        offset += 4  # type, unused
+        parent = struct.unpack_from('<i', data, offset)[0]
+        offset += 4
+        matrix_bytes = struct.unpack_from('<I', data, offset)[0]
+        offset += 4
+        matrix = None
+        if matrix_bytes == 64:
+            values = struct.unpack_from('<16f', data, offset)
+            offset += 64
+            matrix = np.array(values, dtype=float).reshape(4, 4)  # row-major as authored
+        else:
+            offset += matrix_bytes
+        name, offset = read_cstr(data, offset)
+
+        if matrix is None:
+            print(f"{i} parent={parent} name={name!r} NO MATRIX ({matrix_bytes} bytes)")
+            continue
+
+        det = np.linalg.det(matrix[:3, :3])
+        translation = matrix[3, :3]  # row-vector convention: translation lives in the last row
+        flag = "  <-- MIRRORED/NEGATIVE SCALE" if det < 0 else ""
+        print(f"{i} parent={parent} name={name!r} det3x3={det:.5f} translation={translation}{flag}")
+
+
+if __name__ == '__main__':
+    if len(sys.argv) != 2:
+        print(__doc__)
+        raise SystemExit(1)
+    main(sys.argv[1])

+ 193 - 0
tools/rdc_dump.py

@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""RenderDoc capture inspector for this engine's GL puppet-compositing pipeline.
+
+Runs inside qrenderdoc's embedded Python interpreter (not a normal python3 - the
+`renderdoc`/`qrenderdoc` modules only exist there). Invoke as:
+
+    env -u WAYLAND_DISPLAY QT_QPA_PLATFORM=xcb DISPLAY=:0 \
+        RDC_CAPFILE=/path/to/capture.rdc RDC_MODE=positions RDC_EVENTID=1775 \
+        qrenderdoc --python tools/rdc_dump.py
+
+Modes (set via RDC_MODE):
+  list        - print eventId/name/numIndices/outputs for every draw call in the capture.
+  positions   - dump the a_Position vertex buffer for RDC_EVENTID (comma-separated list ok).
+  texcoords   - dump the a_TexCoord vertex buffer for RDC_EVENTID.
+  uniforms    - dump the $Globals uniform block (g_ModelViewProjectionMatrix etc) for RDC_EVENTID.
+  resources   - dump the fragment shader's bound texture resourceIds/names for RDC_EVENTID,
+                useful for confirming two draws are part of the same object's FBO->composite chain
+                (an intermediate pass's output resourceId showing up as a later draw's input).
+  screenshot  - save the final swapchain image of the whole frame (last draw call) to RDC_OUT (PNG).
+
+Several embedded-API quirks aren't documented anywhere obvious - see the per-function comments
+below (GetConstantBlock's resourceId lives under .descriptor.resource not .resourceId, resource
+names need a manual resourceId->name dict from controller.GetResources() since there's no
+GetResourceName(), etc).
+"""
+import os
+import sys
+import struct
+
+import renderdoc as rd
+
+CAPFILE = os.environ.get("RDC_CAPFILE")
+MODE = os.environ.get("RDC_MODE", "list")
+EVENTIDS = [int(e) for e in os.environ.get("RDC_EVENTID", "").split(",") if e.strip()]
+OUT = os.environ.get("RDC_OUT")
+
+out_lines = []
+
+
+def log(s):
+    print(s)
+    out_lines.append(str(s))
+
+
+def walk_actions(controller, fn):
+    def recurse(act):
+        fn(act)
+        for c in act.children:
+            recurse(c)
+    for a in controller.GetRootActions():
+        recurse(a)
+
+
+def dump_vbuffer(controller, eid, attr_name):
+    controller.SetFrameEvent(eid, True)
+    state = controller.GetPipelineState()
+    vp = state.GetViewport(0)
+    vbs = state.GetVBuffers()
+    attrs = state.GetVertexInputs()
+    log(f"eventId={eid} viewport=({vp.x},{vp.y},{vp.width},{vp.height})")
+    for attr in attrs:
+        if attr.name != attr_name:
+            continue
+        vb = vbs[attr.vertexBuffer]
+        total_offset = attr.byteOffset + vb.byteOffset
+        data = controller.GetBufferData(vb.resourceId, total_offset, 0)
+        comp = attr.format.compCount
+        n = (len(data) // 4 // comp) * comp
+        vals = struct.unpack_from(f"<{n}f", data, 0)
+        log(f"  {attr_name} ({comp} floats/vertex, {n // comp} vertices): {vals}")
+        if comp >= 2:
+            xs = vals[0::comp]
+            ys = vals[1::comp]
+            log(f"  {attr_name} X range: {min(xs)} to {max(xs)} (center {(min(xs)+max(xs))/2})")
+            log(f"  {attr_name} Y range: {min(ys)} to {max(ys)} (center {(min(ys)+max(ys))/2})")
+
+
+def dump_uniforms(controller, eid):
+    controller.SetFrameEvent(eid, True)
+    state = controller.GetPipelineState()
+    refl = state.GetShaderReflection(rd.ShaderStage.Vertex)
+    vs_id = state.GetShader(rd.ShaderStage.Vertex)
+    # GetConstantBlock's return type (BoundCBuffer) hides the actual resourceId/byteOffset/byteSize
+    # under .descriptor, not as direct attributes - not obvious from the method name.
+    cbuf = state.GetConstantBlock(rd.ShaderStage.Vertex, 0, 0)
+    d = cbuf.descriptor
+    variables = controller.GetCBufferVariableContents(
+        state.GetGraphicsPipelineObject(), vs_id, rd.ShaderStage.Vertex, refl.entryPoint, 0,
+        d.resource, d.byteOffset, d.byteSize)
+    log(f"eventId={eid} $Globals:")
+    for v in variables:
+        log(f"  {v.name}: {v.value.f32v[:16]}")
+
+
+def dump_resources(controller, eid, resnames):
+    controller.SetFrameEvent(eid, True)
+    state = controller.GetPipelineState()
+    def act_info():
+        found = [(None, None)]
+        def fn(act):
+            if act.eventId == eid:
+                found[0] = (act.GetName(controller.GetStructuredFile()), list(act.outputs))
+        walk_actions(controller, fn)
+        return found[0]
+    name, outputs = act_info()
+    log(f"eventId={eid} name={name} outputs={outputs}")
+    for ro in state.GetReadOnlyResources(rd.ShaderStage.Fragment):
+        rid = ro.descriptor.resource
+        log(f"  fragment slot={ro.access.index} resourceId={rid} name={resnames.get(rid, '?')}")
+
+
+def do_list(controller):
+    def fn(act):
+        if act.flags & rd.ActionFlags.Drawcall:
+            log(f"eventId={act.eventId} name={act.GetName(controller.GetStructuredFile())} "
+                f"numIndices={act.numIndices} outputs={list(act.outputs)}")
+    walk_actions(controller, fn)
+
+
+def do_screenshot(controller):
+    last_draw = [None]
+    def fn(act):
+        if act.flags & rd.ActionFlags.Drawcall:
+            last_draw[0] = act
+    walk_actions(controller, fn)
+    if last_draw[0] is None:
+        log("no draw calls found")
+        return
+    controller.SetFrameEvent(last_draw[0].eventId, True)
+    log(f"last draw eventId={last_draw[0].eventId}")
+    texsave = rd.TextureSave()
+    # the output resourceId lives on the *action*, not the controller - there's no
+    # controller.GetOutputTargets() despite PipeState having one for the currently bound FBO.
+    texsave.resourceId = last_draw[0].outputs[0]
+    if texsave.resourceId == rd.ResourceId.Null():
+        log("no output resource on last draw")
+        return
+    texsave.destType = rd.FileType.PNG
+    texsave.mip = 0
+    texsave.slice.sliceIndex = 0
+    ok = controller.SaveTexture(texsave, OUT or "screenshot.png")
+    log(f"SaveTexture -> {OUT or 'screenshot.png'} ok={ok}")
+
+
+def sampleCode(controller):
+    if MODE == "list":
+        do_list(controller)
+    elif MODE == "positions":
+        for eid in EVENTIDS:
+            dump_vbuffer(controller, eid, "a_Position")
+    elif MODE == "texcoords":
+        for eid in EVENTIDS:
+            dump_vbuffer(controller, eid, "a_TexCoord")
+    elif MODE == "uniforms":
+        for eid in EVENTIDS:
+            dump_uniforms(controller, eid)
+    elif MODE == "resources":
+        resnames = {r.resourceId: r.name for r in controller.GetResources()}
+        for eid in EVENTIDS:
+            dump_resources(controller, eid, resnames)
+    elif MODE == "screenshot":
+        do_screenshot(controller)
+    else:
+        log(f"unknown RDC_MODE={MODE!r}")
+
+
+def main():
+    if not CAPFILE:
+        log("RDC_CAPFILE env var not set")
+        return
+    import time
+    pyrenderdoc.LoadCapture(CAPFILE, rd.ReplayOptions(), CAPFILE, False, True)
+    for _ in range(200):
+        if pyrenderdoc.IsCaptureLoaded():
+            break
+        time.sleep(0.1)
+    log(f"IsCaptureLoaded: {pyrenderdoc.IsCaptureLoaded()}")
+    pyrenderdoc.Replay().BlockInvoke(sampleCode)
+
+
+try:
+    main()
+except Exception as e:
+    import traceback
+    log(f"EXCEPTION: {e}")
+    log(traceback.format_exc())
+
+log_path = OUT + ".log" if (OUT and MODE == "screenshot") else OUT
+if log_path:
+    with open(log_path, "w") as f:
+        f.write("\n".join(out_lines))
+
+os._exit(0)

+ 77 - 0
tools/rdc_trigger.c

@@ -0,0 +1,77 @@
+// LD_PRELOAD companion for headless RenderDoc GL captures - no windowing system to send the
+// default F12 hotkey to, and no pyrenderdoc target-control setup in this environment, so this
+// calls the in-application C API directly instead. On load, waits a few seconds (long enough for
+// the target's GL context and first frames to exist), then triggers one capture.
+//
+// Build:   gcc -shared -fPIC -o rdc_trigger.so rdc_trigger.c -I/opt/renderdoc/include -ldl -lpthread
+// Use:     env -u WAYLAND_DISPLAY DISPLAY=:0 XDG_SESSION_TYPE=x11 \
+//            LD_PRELOAD="/opt/renderdoc/lib/librenderdoc.so:/path/to/rdc_trigger.so" \
+//            ./your_gl_app [args]
+//
+// Renderdoc's official Linux build only supports xlib/XCB windowing, so GLX-via-XWayland is the
+// only path that opens a capturable GL context here. XDG_SESSION_TYPE must be forced to "x11" -
+// GLFWOpenGLDriver otherwise picks the Wayland/EGL backend whenever the session type says
+// "wayland", even with DISPLAY set, and RenderDoc's hooks never see a GLX context to capture.
+#define _GNU_SOURCE
+#include <stdbool.h>
+#include <dlfcn.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include "renderdoc_app.h"
+
+static void *trigger_thread(void *arg) {
+    (void) arg;
+    const char *delay_env = getenv("RDC_TRIGGER_DELAY_SEC");
+    sleep(delay_env ? (unsigned) atoi(delay_env) : 4);
+
+    void *mod = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD);
+    if (!mod) {
+        fprintf(stderr, "[rdc_trigger] librenderdoc.so not loaded yet\n");
+        return NULL;
+    }
+
+    pRENDERDOC_GetAPI RENDERDOC_GetAPI = (pRENDERDOC_GetAPI) dlsym(mod, "RENDERDOC_GetAPI");
+    if (!RENDERDOC_GetAPI) {
+        fprintf(stderr, "[rdc_trigger] no RENDERDOC_GetAPI symbol\n");
+        return NULL;
+    }
+
+    RENDERDOC_API_1_6_0 *rdoc = NULL;
+    int ok = RENDERDOC_GetAPI(eRENDERDOC_API_Version_1_6_0, (void **) &rdoc);
+    if (!ok || !rdoc) {
+        fprintf(stderr, "[rdc_trigger] RENDERDOC_GetAPI failed\n");
+        return NULL;
+    }
+
+    const char *capfile = getenv("RDC_TRIGGER_CAPFILE");
+    if (capfile) {
+        rdoc->SetCaptureFilePathTemplate(capfile);
+    }
+    fprintf(stderr, "[rdc_trigger] path template: %s\n", rdoc->GetCaptureFilePathTemplate());
+    fprintf(stderr, "[rdc_trigger] IsFrameCapturing before trigger: %u\n", rdoc->IsFrameCapturing());
+    fprintf(stderr, "[rdc_trigger] triggering capture now\n");
+    rdoc->TriggerCapture();
+    for (int i = 0; i < 10; i++) {
+        usleep(200000);
+        fprintf(stderr, "[rdc_trigger] tick %d IsFrameCapturing=%u NumCaptures=%d\n", i, rdoc->IsFrameCapturing(),
+                rdoc->GetNumCaptures());
+    }
+    fprintf(stderr, "[rdc_trigger] done, num captures=%d\n", rdoc->GetNumCaptures());
+    if (rdoc->GetNumCaptures() > 0) {
+        char pathbuf[1024] = {0};
+        uint32_t pathlen = sizeof(pathbuf);
+        uint64_t ts = 0;
+        rdoc->GetCapture(0, pathbuf, &pathlen, &ts);
+        fprintf(stderr, "[rdc_trigger] capture 0 path: %s\n", pathbuf);
+    }
+    return NULL;
+}
+
+__attribute__((constructor))
+static void rdc_trigger_init(void) {
+    pthread_t t;
+    pthread_create(&t, NULL, trigger_thread, NULL);
+    pthread_detach(t);
+}

BIN
tools/rdc_trigger.so


+ 5 - 0
tools/requirements.txt

@@ -0,0 +1,5 @@
+# Python deps for the debugging/analysis scripts in this directory (not needed for the
+# C++ build itself). Install with: pip install -r tools/requirements.txt
+lz4       # decode_tex.py - LZ4-compressed texture mipmaps
+numpy     # mdl_bones.py - matrix determinant; also handy for ad-hoc mesh/UV analysis
+Pillow    # viewing/saving decoded textures and framebuffer dumps as PNG