diff --git a/Assets/archive/starting-background-v1-2026-11-06.jpg b/Assets/archive/starting-background-v1-2026-11-06.jpg
new file mode 100644
index 0000000..e23965e
Binary files /dev/null and b/Assets/archive/starting-background-v1-2026-11-06.jpg differ
diff --git a/Assets/starting-background.jpg b/Assets/starting-background.jpg
index e23965e..555915c 100644
Binary files a/Assets/starting-background.jpg and b/Assets/starting-background.jpg differ
diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs
index 37135bb..97b12f6 100644
--- a/ViewModels/MainViewModel.Background.cs
+++ b/ViewModels/MainViewModel.Background.cs
@@ -429,7 +429,7 @@ public partial class MainViewModel
/// drops duplicates/stale rows, re-seeds missing ones.
/// Bump when a bundled background image is replaced. Layouts stamped with an older version
/// re-seed their DEFAULT art on next load; a creator's own custom background is never touched.
- private const int CurrentDefaultArtVersion = 2;
+ private const int CurrentDefaultArtVersion = 4;
/// Re-stamp the shipped art for scenes still using the default. Without this, a replaced
/// starting-background.jpg reaches new installs only: the asset is hash-keyed into the layout
@@ -438,12 +438,19 @@ public partial class MainViewModel
{
if (_layoutStore.LoadDefaultArtVersion() >= CurrentDefaultArtVersion) return;
- foreach (var background in AllBackgrounds())
+ // Every scene's background, not AllBackgrounds(): that helper only returns the LIVE scene's
+ // capture-flavoured rows, which is why version 2 healed nothing (2026-11-06).
+ var cleared = 0;
+ foreach (var scene in Scenes)
+ foreach (var background in scene.Elements.OfType().Where(element => element.IsBackground))
{
- if (!background.UseDefaultBackground) continue; // the creator chose their own art
- background.AssetId = null; // seed re-stamps it below
+ if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath))
+ continue; // the creator chose their own art
+ background.AssetId = null; // the seed below re-stamps the bundled art
+ cleared++;
}
+ AppLog.Write($"Default art version {CurrentDefaultArtVersion}: re-seeding {cleared} background(s)");
_layoutStore.SaveDefaultArtVersion(CurrentDefaultArtVersion);
}
@@ -516,7 +523,10 @@ public partial class MainViewModel
try
{
- var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute);
+ // Assembly-qualified on purpose: the unqualified form resolves against the ENTRY assembly,
+ // which is LlamaCasty.exe in production but the test host under `dotnet test` — so the seed
+ // silently did nothing there (and the art test could not see the bundled image).
+ var uri = new Uri($"pack://application:,,,/LlamaCasty;component/Assets/{asset}", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
diff --git a/ytLive.Tests/BackgroundDefaultArtTests.cs b/ytLive.Tests/BackgroundDefaultArtTests.cs
new file mode 100644
index 0000000..e567b4d
--- /dev/null
+++ b/ytLive.Tests/BackgroundDefaultArtTests.cs
@@ -0,0 +1,91 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// 2026-11-06: replacing a bundled background reached new installs only. Default art is hash-keyed into the
+/// layout database on first seed, so an existing profile kept the old image for ever — and the first
+/// attempt at a version heal silently did nothing because it iterated `AllBackgrounds()`, which returns
+/// only the LIVE scene's rows.
+///
+[Collection("RealApp")]
+public sealed class BackgroundDefaultArtTests
+{
+ private readonly RealAppHost _app;
+
+ public BackgroundDefaultArtTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void ReplacingTheBundledArt_ReSeedsAnExistingProfile() => _app.Run(() =>
+ {
+ // The bundled art is a WPF resource, so this needs a real Application to resolve the pack URI.
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-art-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ // First launch: the seeded art is whatever ships today.
+ var first = new MainViewModel();
+ var firstId = StartingBackgroundId(first);
+ Assert.False(string.IsNullOrWhiteSpace(firstId), "the bundled Starting art never seeded");
+ var firstBytes = AssetLength(tempDb, firstId!);
+
+ // Simulate a profile stamped with the OLD art: point the Starting background at a planted
+ // asset and mark the layout as an older art vintage. The heal must re-stamp the shipped art.
+ using (var connection = new SqliteConnection($"Data Source={tempDb}"))
+ {
+ connection.Open();
+ using var plant = connection.CreateCommand();
+ plant.CommandText = """
+ INSERT OR REPLACE INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
+ VALUES ('old-art', 'deadbeef', zeroblob(1024), 1920, 1080);
+ UPDATE Source SET AssetId = 'old-art' WHERE IsBackground = 1;
+ """;
+ plant.ExecuteNonQuery();
+ }
+ SqliteConnection.ClearAllPools();
+ using (var store = new LayoutStore(tempDb))
+ store.SaveDefaultArtVersion(1);
+
+ var second = new MainViewModel();
+ var secondId = StartingBackgroundId(second);
+ Assert.NotNull(secondId);
+
+ // Re-stamped to the SHIPPED art — which, being the same bytes, is the same asset row the
+ // first launch created. What matters is that the planted "old art" is gone.
+ Assert.NotEqual("old-art", secondId);
+ Assert.Equal(firstId, secondId);
+ Assert.Equal(File.ReadAllBytes(Path.Combine(
+ AppContext.BaseDirectory, "..", "..", "..", "..", "Assets", "starting-background.jpg")).Length,
+ AssetLength(tempDb, secondId!));
+ Assert.NotEqual(firstBytes, 0);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+
+ private static string? StartingBackgroundId(MainViewModel vm)
+ => vm.Scenes
+ .Where(scene => ytLive.Models.SceneCatalog.Is(scene.Name, ytLive.Models.SceneCatalog.Starting))
+ .SelectMany(scene => scene.Elements.OfType())
+ .FirstOrDefault(element => element.IsBackground)?.AssetId;
+
+ private static int AssetLength(string dbPath, string assetId)
+ {
+ using var connection = new SqliteConnection($"Data Source={dbPath}");
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT length(Data) FROM Asset WHERE Id = $id;";
+ cmd.Parameters.AddWithValue("$id", assetId);
+ return Convert.ToInt32(cmd.ExecuteScalar());
+ }
+}