Преглед изворни кода

tons of fixes and tons todo's

UwU пре 1 месец
родитељ
комит
af83312b4e

+ 21 - 5
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -473,8 +473,12 @@ void WallpaperApplication::advancePlaylist (
 
 	this->setupPropertiesForProject (*project);
 
-	// same reason as checkHotswapRequest() - keep the outgoing project alive past setWallpaper()
-	auto outgoingProject = std::move (this->m_backgrounds[screen]);
+	// same reason as checkHotswapRequest() - keep the outgoing project alive past setWallpaper(),
+	// and past this function returning: if fromWallpaper() below throws (e.g. the new scene
+	// references a missing asset), the old CWallpaper for this screen is still installed and
+	// still rendering off this project's data, so a try-block-local variable freed in the catch
+	// below would leave it dangling
+	this->m_retiredProjects.push_back (std::move (this->m_backgrounds[screen]));
 	this->m_backgrounds[screen] = std::move (project);
 
 	const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
@@ -802,6 +806,20 @@ void WallpaperApplication::checkHotswapRequest () {
 	sLog.out ("Hotswapping wallpaper layers");
     }
 
+    // CWallpaper holds a raw reference into its Project (m_wallpaperData). Each loop iteration below
+    // used to keep its own outgoing project alive only via a try-block-local variable, on the assumption
+    // that it only needed to outlive setWallpaper() a few lines later - but if anything after the move
+    // on the next line throws (e.g. CWallpaper::fromWallpaper() failing to load an asset the new scene
+    // references, like a missing "models/bar.json"), the catch block below is reached with setWallpaper()
+    // never having run: the OLD CWallpaper for that screen is still installed and still rendering every
+    // frame off data that local variable is about to free at scope exit. The very next thing that touches
+    // every installed wallpaper is applyAudioPolicy() below, which segfaulted inside
+    // CSound::applyEffectiveVolume() reading a dangling Sound reference - confirmed by a real crash dump
+    // whose reported filesystem error (a missing model referenced mid-hotswap) lines up exactly with this
+    // window. Collecting them here instead, alive until this whole function returns (well past
+    // applyAudioPolicy()), fixes that regardless of which screen's swap failed or why.
+    std::vector<ProjectUniquePtr> outgoingProjects;
+
     for (auto& [screen, background] : this->m_backgrounds) {
 	const std::string targetPath = request.path.value_or (this->resolveScreenBackgroundPath (screen));
 
@@ -812,9 +830,7 @@ void WallpaperApplication::checkHotswapRequest () {
 	    this->setupAudioSensitivityForProject (*project);
 	    this->setupSoundVolumeForProject (*project);
 
-	    // CWallpaper holds a raw reference into its Project (m_wallpaperData), so the outgoing
-	    // project must outlive setWallpaper() below, which destroys the CWallpaper using it
-	    auto outgoingProject = std::move (background);
+	    outgoingProjects.push_back (std::move (background));
 	    background = std::move (project);
 
 	    const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);

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

@@ -191,6 +191,12 @@ private:
     ApplicationContext& m_context;
     std::map<std::string, ProjectUniquePtr> m_backgrounds {};
     std::map<std::string, ActivePlaylist> m_activePlaylists {};
+    // Projects displaced by a wallpaper swap that failed partway through (see advancePlaylist and
+    // checkHotswapRequest) - kept alive here rather than freed at the point of failure, since the
+    // still-installed old CWallpaper holds a raw reference into them. Deliberately never pruned; these
+    // are rare (asset-loading failures mid-swap), not a hot path, and an app-lifetime retention is a much
+    // smaller cost than the use-after-free it replaces.
+    std::vector<ProjectUniquePtr> m_retiredProjects {};
 
     std::unique_ptr<WallpaperEngine::Audio::Drivers::Detectors::AudioPlayingDetector> m_audioDetector = nullptr;
     std::unique_ptr<WallpaperEngine::Audio::AudioContext> m_audioContext = nullptr;

+ 59 - 12
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -714,15 +714,41 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	// go from this consistent origin-space into screen/pixel space. The bone's meshPosition, however,
 	// comes from getAttachmentPointMeshTransform() already in that same unflipped origin-space
 	// convention (see its own doc comment) - so it must be folded in raw, exactly like a normal child's
-	// local.origin is, not re-flipped a second time. A previous version of this code negated
-	// meshTransform->position.y (and, to stay "consistent" with that, meshTransform->angle) by analogy
-	// with updatePuppetPositionBuffer's Y-flip - but that flip belongs to a *different* pipeline (baking
-	// mesh-space directly to screen-space for the puppet's own vertices), not to this one, and mixing it
-	// into the otherwise-unflipped origin-space chain was very likely a real bug: large/rotated
-	// attachments (e.g. an eye attached to a puppet with a real bone rotation) landed far from the
-	// correct position, while small/near-zero-rotation cases happened to look close enough to right to
-	// go unnoticed. Not yet re-verified against a real capture - if this turns out wrong, the negation
-	// this replaced is in this file's git history.
+	// local.origin is, not re-flipped a second time. Confirmed against a real wallpaper with a genuinely
+	// large bone rotation (mikasa/3764765600's "eye" attachment, ~-45 degrees): before this fix the
+	// attachment landed off the top edge of the screen entirely; with position un-negated it lands
+	// correctly on the face.
+	//
+	// anchorAngle (the bone's rotation, same sign/no-flip as position) rotates the attached child's own
+	// local-origin nudge below, via the same offset-rotation every normal child already goes through -
+	// that's required for *position* to track the bone correctly: a child's own declared origin is a
+	// small offset in the attachment point's local frame, so it has to rotate along with whatever that
+	// frame's current orientation is, same as it already scales along with the parent's current scale.
+	// It also feeds the child's own final stored angle two lines below - the mathematically consistent
+	// choice (attachmentWorldMatrix * childLocalMatrix), and the one actually confirmed working: mikasa's
+	// eye (the only attachment point found so far riding a bone with genuine non-zero rotation) is
+	// visible with this formula, just not at the correct angle (her declared local angle of ~44.6 degrees
+	// and the eye bone's ~-45 degree rotation nearly cancel to ~0 net rotation, rendering as a thin
+	// angular sliver instead of a natural lash contour - a real, unsolved cosmetic bug, tracked
+	// separately, not this line).
+	//
+	// Two variants were tried and reverted, both regressions confirmed by the user on real hardware, not
+	// just sandbox: (1) flipping only meshTransform->angle's sign within anchorAngle - since anchorAngle
+	// also drives the offset-rotation above, this swung the eye's own (~355-unit) local-origin nudge by
+	// nearly 90 degrees and pushed the object off the right edge of the screen entirely ("eyes completely
+	// disappeared"). (2) splitting a separate finalAngle that dropped the bone's rotation from the final
+	// angle entirely, reasoning that position and orientation could use different angles - this looked
+	// like a plausible eyelash contour in an isolated sandbox crop, but the eye's own detail marks (a
+	// small highlight dot, iris shading, a few lash strokes - confirmed via decode_tex.py on "mikasa
+	// eye.tex": barely 0.7% of the canvas is non-transparent) are precisely positioned to overlay a
+	// specific closed-eye crease baked into mikasaback's own texture; changing the mesh's rotation swings
+	// those small marks to different screen pixels even though the object's own bounding-box center
+	// doesn't move, and evidently rotated them off that tiny target entirely - user confirmed "eyes are
+	// still invisible" with a real screenshot showing bare skin, no eye at all, where the sandbox crop had
+	// suggested something was there. Reverted back to the single-anchorAngle formula below, which is the
+	// last state confirmed actually visible (if wrongly rotated) on real hardware - a real fix for the
+	// rotation needs to explain why a *different* angle would still hit the same crease, not just look
+	// better in isolation.
 	glm::vec3 anchorOrigin = resolved.origin;
 	float anchorAngle = resolved.angle;
 	glm::vec2 anchorScale = { 1.0f, 1.0f };
@@ -1561,6 +1587,27 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		}
 	    }
 
+	    {
+		static int mikasaEyeDumpCounter = 0;
+		if (this->getId () == 603 && mikasaEyeDumpCounter++ == 5) {
+		    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/mikasa_eye_bakepass_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 mikasa eye bake pass: ", w, "x", h, " to /tmp/mikasa_eye_bakepass_dump.raw");
+			}
+		    }
+		}
+	    }
+
 	    {
 		static int dumpCounter = 0;
 		if (this->getId () == 418 && dumpCounter++ == 100) {
@@ -2212,9 +2259,9 @@ CImage::ResolvedTransform CImage::updateGeometryBuffers () {
 	this->m_transformDiagnosticLogged = true;
 	sLog.out (
 	    "Transform for ", this->getImage ().name, " (", this->getId (), "): resolvedOrigin=(", origin.x, ",",
-	    origin.y, ") resolvedScale=", scale.x, " size=(", size.x, ",", size.y, ") m_pos=(", this->m_pos.x, ",",
-	    this->m_pos.y, ",", this->m_pos.z, ",", this->m_pos.w, ") sceneWidth=", sceneWidth, " sceneHeight=",
-	    sceneHeight
+	    origin.y, ") resolvedScale=", scale.x, " resolvedAngleDeg=", glm::degrees (transform.angle), " size=(",
+	    size.x, ",", size.y, ") m_pos=(", this->m_pos.x, ",", this->m_pos.y, ",", this->m_pos.z, ",",
+	    this->m_pos.w, ") sceneWidth=", sceneWidth, " sceneHeight=", sceneHeight
 	);
     }
 

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

@@ -961,7 +961,16 @@ void CPass::setupTextureUniforms () {
 	namestream << "g_Texture" << textureIndex << "Resolution";
 
 	texture = this->resolveTexture (expectedTexture->texture, textureIndex, texture);
-	this->addUniform (namestream.str (), texture->getResolution ());
+	const glm::vec4* res = texture->getResolution ();
+
+	if (this->m_renderable.getId () == 13) {
+	    sLog.out (
+		"TEMP-DIAG texture resolution uniform for object 13: name=", namestream.str (), " index=", textureIndex,
+		" res=(", res->x, ",", res->y, ",", res->z, ",", res->w, ") shader=", this->m_pass.shader
+	    );
+	}
+
+	this->addUniform (namestream.str (), res);
     }
 
     this->addUniform ("g_Texture0Resolution", &this->m_texture0Resolution);

+ 136 - 5
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -71,18 +71,149 @@ CScene::CScene (
 	this->createObject (*object);
     }
 
-    // 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
+    // EXPERIMENTAL (2026-08-10): id-order and size/footprint-order were both tried and reverted as
+    // defaults for objects with no explicit sortorder (see CLAUDE.md for why each broke a different,
+    // previously-correct wallpaper). This tries a third signal, derived from real structural data instead
+    // of a property proxy: attachment/parent chain depth. A puppet piece riding another via "attachment"
+    // (e.g. hair attached to a head attached to a body) should paint after whatever it's attached to -
+    // that's real information already present in the scene graph, not a guess. Depth is the primary key;
+    // array position remains the tiebreak for equal-depth objects (including the common case of no
+    // parent chain at all, depth 0 for everyone), so this is a no-op for any wallpaper whose objects are
+    // all at the same depth - it only changes anything for wallpapers with real parent/attachment chains.
+    constexpr int kMaxChainDepth = 32;
+    const auto computeChainDepth = [&scene] (const Object* object) {
+	int depth = 0;
+	const Object* current = object;
+	while (current->parent.has_value () && depth < kMaxChainDepth) {
+	    const auto it = std::ranges::find_if (
+		scene->objects, [&current] (const auto& o) { return o->id == current->parent.value (); }
+	    );
+	    if (it == scene->objects.end ()) {
+		break;
+	    }
+	    current = it->get ();
+	    depth++;
+	}
+	return depth;
+    };
+
     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));
+	const int fallback = computeChainDepth (object) * 1'000'000 + index;
+	objectsByPaintOrder.emplace_back (object, object->sortOrder.value_or (fallback));
     }
     std::ranges::stable_sort (objectsByPaintOrder, [] (const auto& a, const auto& b) { return a.second < b.second; });
 
+    // EXPERIMENTAL (2026-08-10): fixes a real compositing seam found on a specific wallpaper ("asagi",
+    // 3221531573) without touching the general-purpose sort above. That wallpaper has a full-canvas
+    // background layer whose alpha channel has soft-edged holes cut for several smaller detail overlays
+    // (eyes, cheek highlights, hair strands) to show through - 6 of those 7 detail layers already draw
+    // after (on top of) the background in raw array order, which is correct: a hard-edged detail sprite
+    // fully covers the background's soft hole edge with no visible seam. Exactly one (the eye layer) is
+    // authored the other way around - it draws before/underneath the background - so the background's own
+    // soft hole edge partially alpha-blends its own color over the already-drawn eye at the feather zone,
+    // producing a visible ring. Confirmed by decoding both textures directly (tools/decode_tex.py): the
+    // background has a genuine soft alpha gradient at the hole boundary, the eye layer's own alpha is
+    // pure 0/255 with no soft edge of its own, and its opaque footprint fully contains the hole with
+    // 60-120px of margin on every side - so drawing it last cleanly overwrites the seam either way.
+    //
+    // Two earlier attempts at a *global* default sort key (id-ascending, size-descending - see the
+    // deeper history in the project's CLAUDE.md) each fixed this exact case but broke other, previously-
+    // correct wallpapers, because `id` and declared `size` aren't reliable depth proxies in general - a
+    // puppet's `size` reflects its mesh bounding box, not visual prominence (broke koshini's hair), and
+    // `id` assignment doesn't correlate with paint order at all in some scenes (a 3528590419 object is
+    // the backmost layer despite having the highest id of its whole sibling group). This is deliberately
+    // narrower than either: only reorders a pair when one object's bounding box is *fully contained*
+    // inside another's, and the container is within 10% of the full scene size (i.e. looks like an actual
+    // background, not just a coincidentally-large sprite) - restricted further to plain (non-puppet),
+    // non-utility (models/util/*), fully translucent-blended objects with no parent/attachment chain and
+    // no explicit sortorder, so it can't touch anything the depth-based sort above or an explicit
+    // sortorder already handles. Checked against every wallpaper with qualifying top-level objects tested
+    // this session (mikasa's lens-flare chain, 3528590419's background/audio-bar pair) - the "near
+    // full-scene container" requirement is what keeps this from matching either: a lens flare's glow
+    // sprite is much smaller than the scene, and 3528590419's "Audio bar" is a reactive utility layer
+    // (models/util/composelayer.json), already excluded by the utility-path check.
+    const auto isPlainTranslucentTopLevelImage = [] (const Object* object) -> const Image* {
+	if (object->parent.has_value () || object->attachment.has_value () || object->sortOrder.has_value ()) {
+	    return nullptr;
+	}
+	const auto* image = dynamic_cast<const Image*> (object);
+	if (image == nullptr || image->model == nullptr || image->model->puppet.has_value ()) {
+	    return nullptr;
+	}
+	if (image->model->filename.starts_with ("models/util/")) {
+	    return nullptr;
+	}
+	if (image->model->material == nullptr || image->model->material->passes.empty ()) {
+	    return nullptr;
+	}
+	for (const auto& pass : image->model->material->passes) {
+	    if (pass->blending != BlendingMode_Translucent) {
+		return nullptr;
+	    }
+	}
+	return image;
+    };
+
+    struct ImageBounds {
+	const Object* object;
+	glm::vec2 min;
+	glm::vec2 max;
+    };
+
+    std::vector<ImageBounds> candidates;
+    for (const auto& [object, sortKey] : objectsByPaintOrder) {
+	const Image* image = isPlainTranslucentTopLevelImage (object);
+	if (image == nullptr) {
+	    continue;
+	}
+	const glm::vec3 origin = object->origin->value->getVec3 ();
+	const glm::vec2 half = image->size / 2.0f;
+	candidates.push_back ({ object, { origin.x - half.x, origin.y - half.y }, { origin.x + half.x, origin.y + half.y } });
+    }
+
+    for (const auto& container : candidates) {
+	const glm::vec2 containerSize = container.max - container.min;
+	if (containerSize.x < static_cast<float> (sceneWidth) * 0.9f
+	    || containerSize.y < static_cast<float> (sceneHeight) * 0.9f) {
+	    continue;
+	}
+
+	for (const auto& contained : candidates) {
+	    if (contained.object == container.object) {
+		continue;
+	    }
+	    const glm::vec2 containedSize = contained.max - contained.min;
+	    if (containedSize.x * containedSize.y >= containerSize.x * containerSize.y) {
+		continue;
+	    }
+	    const bool fullyInside = contained.min.x >= container.min.x && contained.min.y >= container.min.y
+		&& contained.max.x <= container.max.x && contained.max.y <= container.max.y;
+	    if (!fullyInside) {
+		continue;
+	    }
+
+	    const auto containedIt = std::ranges::find_if (
+		objectsByPaintOrder, [&contained] (const auto& p) { return p.first == contained.object; }
+	    );
+	    const auto containerIt = std::ranges::find_if (
+		objectsByPaintOrder, [&container] (const auto& p) { return p.first == container.object; }
+	    );
+	    if (containedIt < containerIt) {
+		auto entry = *containedIt;
+		objectsByPaintOrder.erase (containedIt);
+		// containerIt was invalidated by the erase above if it came after containedIt, which it did
+		// (containedIt < containerIt) - re-find it before inserting relative to it
+		const auto refreshedContainerIt = std::ranges::find_if (
+		    objectsByPaintOrder, [&container] (const auto& p) { return p.first == container.object; }
+		);
+		objectsByPaintOrder.insert (std::next (refreshedContainerIt), entry);
+	    }
+	}
+    }
+
     for (const auto& [object, sortKey] : objectsByPaintOrder) {
 	this->addObjectToRenderOrder (*object);
     }