diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29cee641..59c12b87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
All notable changes to Chromatics are documented here.
+## 4.4.0
+
+- Added support for FFXIV patch 7.56.
+- **New:** BST support.
+- Minor bug fixes.
+- Updated backend dependencies.
+
## 4.3.36
- Updated to Sharlayan 9.2.1.
diff --git a/Chromatics.DecoratorHarnessUI/Chromatics.DecoratorHarnessUI.csproj b/Chromatics.DecoratorHarnessUI/Chromatics.DecoratorHarnessUI.csproj
index 27349f03..e911f0f3 100644
--- a/Chromatics.DecoratorHarnessUI/Chromatics.DecoratorHarnessUI.csproj
+++ b/Chromatics.DecoratorHarnessUI/Chromatics.DecoratorHarnessUI.csproj
@@ -50,10 +50,10 @@
-
-
-
-
+
+
+
+
diff --git a/Chromatics.Tests/Chromatics.Tests.csproj b/Chromatics.Tests/Chromatics.Tests.csproj
index 8b58cfa7..695e0f1b 100644
--- a/Chromatics.Tests/Chromatics.Tests.csproj
+++ b/Chromatics.Tests/Chromatics.Tests.csproj
@@ -12,7 +12,7 @@
-
+
@@ -38,7 +38,7 @@
-
+
diff --git a/Chromatics.Tests/Models/JobCoverageTests.cs b/Chromatics.Tests/Models/JobCoverageTests.cs
new file mode 100644
index 00000000..10b314b7
--- /dev/null
+++ b/Chromatics.Tests/Models/JobCoverageTests.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Chromatics.Models;
+using Sharlayan.Core.Enums;
+
+namespace Chromatics.Tests.Models;
+
+public class JobCoverageTests
+{
+ // Base classes upgrade into a job and are shown in that job's colours, so
+ // they carry no palette entry of their own. Unknown is the enum's sentinel
+ // for "no job read yet" and is never painted.
+ private static readonly HashSet NotPainted =
+ ["GLD", "PGL", "MRD", "LNC", "ARC", "CNJ", "THM", "ACN", "ROG", "Unknown"];
+
+ private static IEnumerable Jobs()
+ => Enum.GetNames().Where(name => !NotPainted.Contains(name));
+
+ [Fact]
+ public void EveryJobHasBaseAndHighlightColours()
+ {
+ // Guards the job-addition checklist: a new job in Sharlayan without
+ // palette entries here paints from whatever the switch falls through
+ // to, which is silent and easy to miss (BST arrived in 7.56 this way).
+ var fields = typeof(PaletteColorModel)
+ .GetFields(BindingFlags.Public | BindingFlags.Instance)
+ .Select(f => f.Name)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var missing = Jobs()
+ .Where(job => !fields.Contains($"Job{job}Base") || !fields.Contains($"Job{job}Highlight"))
+ .ToList();
+
+ Assert.True(missing.Count == 0, "Missing: " + string.Join(", ", missing));
+ }
+
+ [Fact]
+ public void EveryJobColourRoundTripsThroughTheLegacyPaletteFile()
+ {
+ // Legacy palette files map by name over reflection, so a job whose
+ // entry exists on the model but not here loads back as its default and
+ // quietly discards what the user picked.
+ var dtoProperties = typeof(LegacyColorMappings)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Select(p => p.Name)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var missing = Jobs()
+ .Where(job => !dtoProperties.Contains($"ColorMappingJob{job}Base")
+ || !dtoProperties.Contains($"ColorMappingJob{job}Highlight"))
+ .ToList();
+
+ Assert.True(missing.Count == 0, "Missing: " + string.Join(", ", missing));
+ }
+
+ [Fact]
+ public void BeastmasterIsRegistered()
+ {
+ Assert.Contains("BST", Enum.GetNames());
+ Assert.NotNull(new PaletteColorModel().JobBSTBase);
+ Assert.NotNull(new PaletteColorModel().JobBSTHighlight);
+ }
+}
diff --git a/Chromatics.Tests/Models/PaletteRenameMigrationTests.cs b/Chromatics.Tests/Models/PaletteRenameMigrationTests.cs
new file mode 100644
index 00000000..a7105ea5
--- /dev/null
+++ b/Chromatics.Tests/Models/PaletteRenameMigrationTests.cs
@@ -0,0 +1,55 @@
+using System.Drawing;
+using Chromatics.Models;
+using Newtonsoft.Json;
+
+namespace Chromatics.Tests.Models;
+
+public class PaletteRenameMigrationTests
+{
+ // Palette files are keyed on the field name, so renaming a field drops
+ // whatever the user had saved under the old one. The two weather fields
+ // renamed to their plural in-game spellings carry their old values across
+ // through setter-only aliases.
+ private const string LegacyPalette = """
+ {
+ "version": "3",
+ "WeatherFirestormBase": { "Name": "Firestorm (Base)", "Type": 11, "Color": "Magenta" },
+ "WeatherFirestormHighlight": { "Name": "Firestorm (Highlight)", "Type": 11, "Color": "Teal" },
+ "WeatherUmbralDuststormBase": { "Name": "Umbral Duststorm (Base)", "Type": 11, "Color": "Navy" }
+ }
+ """;
+
+ [Fact]
+ public void ColoursSavedUnderTheOldWeatherKeysSurviveTheRename()
+ {
+ var palette = JsonConvert.DeserializeObject(LegacyPalette);
+
+ Assert.NotNull(palette);
+ Assert.Equal(Color.Magenta.ToArgb(), palette.WeatherFirestormsBase.Color.ToArgb());
+ Assert.Equal(Color.Teal.ToArgb(), palette.WeatherFirestormsHighlight.Color.ToArgb());
+ Assert.Equal(Color.Navy.ToArgb(), palette.WeatherUmbralDuststormsBase.Color.ToArgb());
+ }
+
+ [Fact]
+ public void AnUntouchedEntryKeepsItsDefault()
+ {
+ var palette = JsonConvert.DeserializeObject(LegacyPalette);
+
+ // Absent from the legacy file, so it must fall back to the initialiser
+ // rather than being nulled by the partial deserialise.
+ Assert.NotNull(palette!.WeatherUmbralDuststormsHighlight);
+ Assert.Equal(Color.SandyBrown.ToArgb(), palette.WeatherUmbralDuststormsHighlight.Color.ToArgb());
+ }
+
+ [Fact]
+ public void SavingDropsTheOldKeys()
+ {
+ // The aliases are setter-only, so a re-saved palette carries the new
+ // spelling alone and the legacy keys retire.
+ var json = JsonConvert.SerializeObject(new PaletteColorModel());
+
+ Assert.Contains("WeatherFirestormsBase", json);
+ Assert.DoesNotContain("\"WeatherFirestormBase\"", json);
+ Assert.DoesNotContain("\"WeatherUmbralDuststormBase\"", json);
+ }
+}
diff --git a/Chromatics.Tests/Models/WeatherCoverageTests.cs b/Chromatics.Tests/Models/WeatherCoverageTests.cs
new file mode 100644
index 00000000..13434d02
--- /dev/null
+++ b/Chromatics.Tests/Models/WeatherCoverageTests.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using Chromatics.Models;
+
+namespace Chromatics.Tests.Models;
+
+public class WeatherCoverageTests
+{
+ // ReactiveWeatherProcessor.GetWeatherColor builds the palette field name
+ // from the game's weather name verbatim:
+ // "Weather" + name.Replace(" ", "") + "Base"
+ // A field spelled even slightly differently never resolves, and the weather
+ // silently paints as Unknown instead. Firestorms, Umbral Duststorms and the
+ // three 7.5x additions all landed that way, which is invisible without a
+ // test because nothing throws.
+ //
+ // Names below are taken from the game's Weather sheet.
+ private static readonly string[] GameWeather =
+ [
+ "Clear Skies", "Fair Skies", "Clouds", "Fog", "Rain", "Showers",
+ "Wind", "Gales", "Snow", "Blizzards", "Thunder", "Thunderstorms",
+ "Dust Storms", "Sandstorms", "Heat Waves", "Umbral Wind",
+ "Umbral Static", "Everlasting Light",
+ // Endwalker and Dawntrail zones.
+ // "Astromagnetic Storms" is deliberately absent: its palette field is
+ // singular, so the generic lookup misses it, but its own switch case
+ // assigns WeatherAstromagneticStormBase in both branches and never
+ // consults the generic path. Renaming the field to match would reset
+ // the colour every user has already saved under the old name, for no
+ // change in what they see.
+ "Atmospheric Phantasms", "Illusory Disturbances",
+ "Gravitational Flux", "Meteor Showers", "Sporing Mist",
+ "Annealing Winds", "Glass Storms", "Bubble Bloom",
+ // Plural spellings that previously fell through to Unknown.
+ "Firestorms", "Umbral Duststorms",
+ // Added in the 7.5x patches.
+ "Lyrical Catharsis", "Auroral Flares", "Floracane",
+ ];
+
+ private static string FieldFor(string weather, string suffix)
+ => "Weather" + weather.Replace(" ", "") + suffix;
+
+ [Fact]
+ public void EveryWeatherResolvesToAPaletteColour()
+ {
+ var fields = typeof(PaletteColorModel)
+ .GetFields(BindingFlags.Public | BindingFlags.Instance)
+ .Select(f => f.Name)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var missing = GameWeather
+ .Where(w => !fields.Contains(FieldFor(w, "Base")))
+ .ToList();
+
+ Assert.True(missing.Count == 0,
+ "Weather with no palette colour, so it paints as Unknown: " + string.Join(", ", missing));
+ }
+
+ [Fact]
+ public void WeatherColoursAreRegisteredUnderTheReactiveWeatherCategory()
+ {
+ var palette = new PaletteColorModel();
+
+ foreach (var weather in GameWeather)
+ {
+ var field = typeof(PaletteColorModel)
+ .GetField(FieldFor(weather, "Base"), BindingFlags.Public | BindingFlags.Instance);
+
+ Assert.NotNull(field);
+
+ var mapping = (ColorMapping)field!.GetValue(palette)!;
+ Assert.Equal(Chromatics.Enums.Palette.PaletteTypes.ReactiveWeather, mapping.Type);
+ }
+ }
+}
diff --git a/Chromatics/Chromatics.csproj b/Chromatics/Chromatics.csproj
index c7302bd4..85be1080 100644
--- a/Chromatics/Chromatics.csproj
+++ b/Chromatics/Chromatics.csproj
@@ -10,7 +10,7 @@
net10.0-windows10.0.19041.0
10.0.17763.0
Chromatics.Program
- 4.3.36.0
+ 4.4.0.0
Danielle Thompson