Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.4" />
<PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.1.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>

Expand Down
4 changes: 2 additions & 2 deletions Chromatics.Tests/Chromatics.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
Expand All @@ -38,7 +38,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Sharlayan" Version="9.2.1" />
<PackageReference Include="Sharlayan" Version="9.2.3" />
</ItemGroup>

</Project>
65 changes: 65 additions & 0 deletions Chromatics.Tests/Models/JobCoverageTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> NotPainted =
["GLD", "PGL", "MRD", "LNC", "ARC", "CNJ", "THM", "ACN", "ROG", "Unknown"];

private static IEnumerable<string> Jobs()
=> Enum.GetNames<Actor.Job>().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<Actor.Job>());
Assert.NotNull(new PaletteColorModel().JobBSTBase);
Assert.NotNull(new PaletteColorModel().JobBSTHighlight);
}
}
55 changes: 55 additions & 0 deletions Chromatics.Tests/Models/PaletteRenameMigrationTests.cs
Original file line number Diff line number Diff line change
@@ -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<PaletteColorModel>(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<PaletteColorModel>(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);
}
}
76 changes: 76 additions & 0 deletions Chromatics.Tests/Models/WeatherCoverageTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
30 changes: 15 additions & 15 deletions Chromatics/Chromatics.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<StartupObject>Chromatics.Program</StartupObject>
<Version>4.3.36.0</Version>
<Version>4.4.0.0</Version>
<Authors>Danielle Thompson</Authors>
<!-- ApplicationManifest is conditional: local Debug + Release builds embed
app.manifest (no fusion-identity <msix> element) so VS debug runs and
Expand Down Expand Up @@ -76,26 +76,26 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.4" />
<PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.1.2" />
<PackageReference Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Velopack" Version="1.2.0" />
<PackageReference Include="Sentry" Version="6.6.0" />
<PackageReference Include="Sentry.Profiling" Version="6.6.0" />
<PackageReference Include="Sentry" Version="6.10.0" />
<PackageReference Include="Sentry.Profiling" Version="6.10.0" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="HidSharp" Version="2.6.4" />
<PackageReference Include="HueApi" Version="3.2.0" />
<PackageReference Include="HueApi.ColorConverters" Version="3.1.0" />
<PackageReference Include="HueApi.Entertainment" Version="3.1.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="HueApi" Version="3.3.0" />
<PackageReference Include="HueApi.ColorConverters" Version="3.3.0" />
<PackageReference Include="HueApi.Entertainment" Version="3.3.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
<PackageReference Include="NAudio" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="6.2.0" />
<!-- RGB.NET.Core comes from a local DLL (Build Dependencies/RGB.NET/)
Expand Down Expand Up @@ -135,7 +135,7 @@
<PackageReference Include="RGB.NET.Devices.Wooting" Version="3.2.0" />
<PackageReference Include="RGB.NET.HID" Version="3.2.0" />
<PackageReference Include="RGB.NET.Presets" Version="3.2.0" />
<PackageReference Include="Sharlayan" Version="9.2.1" />
<PackageReference Include="Sharlayan" Version="9.2.3" />
</ItemGroup>

<ItemGroup>
Expand Down
31 changes: 24 additions & 7 deletions Chromatics/Extensions/RGB.NET/Decorators/AudioVisualizerEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public class AudioVisualizerEffect : AbstractUpdateAwareDecorator, ILedGroupDeco
private readonly int _maxRow;
private List<Peak> peaks;
private int columnsPerPeak = 2;
private WasapiLoopbackCapture capture;
private WasapiRecorder capture;
private CaptureDataAvailableHandler _onCaptureData;
private BufferedWaveProvider bufferedWaveProvider;
private const int fftLength = 1024;
private Complex[] fftBuffer = new Complex[fftLength];
Expand Down Expand Up @@ -53,8 +54,19 @@ private void StartAudioCapture()
{
try
{
capture = new WasapiLoopbackCapture();
capture.DataAvailable += OnDataAvailable;
// NAudio 3 retired WasapiLoopbackCapture for the builder. No
// device on WithLoopbackCapture means the default render
// endpoint, which is the system-wide loopback the old type
// captured.
capture = new WasapiRecorderBuilder()
.WithLoopbackCapture()
.Build();

// Kept in a field so OnDetached can unsubscribe the same
// instance; the lambda takes its parameter types from the
// delegate, which spares us naming the WASAPI flag enum.
_onCaptureData = (buffer, flags, devicePosition, qpcPosition) => OnDataAvailable(buffer);
capture.DataAvailable += _onCaptureData;
bufferedWaveProvider = new BufferedWaveProvider(capture.WaveFormat)
{
DiscardOnBufferOverflow = true
Expand All @@ -67,10 +79,13 @@ private void StartAudioCapture()
}
}

private void OnDataAvailable(object sender, WaveInEventArgs e)
// NAudio 3 hands the WASAPI buffer over as a span that is only valid
// for the duration of the call, so copy into the provider here and
// never hold on to it.
private void OnDataAvailable(ReadOnlySpan<byte> buffer)
{
if (bufferedWaveProvider == null) return;
bufferedWaveProvider.AddSamples(e.Buffer, 0, e.BytesRecorded);
bufferedWaveProvider.AddSamples(buffer);
ProcessAudioBuffer();
}

Expand Down Expand Up @@ -99,10 +114,12 @@ public override void OnDetached(IDecoratable decoratable)

if (capture != null)
{
capture.DataAvailable -= OnDataAvailable;
if (_onCaptureData != null)
capture.DataAvailable -= _onCaptureData;
capture.StopRecording();
capture.Dispose();
capture = null;
_onCaptureData = null;
}

bufferedWaveProvider = null;
Expand Down Expand Up @@ -161,7 +178,7 @@ private void ProcessAudioBuffer()
if (available == 0) return;

var audioBytes = new byte[available];
int bytesRead = bufferedWaveProvider.Read(audioBytes, 0, available);
int bytesRead = bufferedWaveProvider.Read(audioBytes);

int bytesPerSample = bufferedWaveProvider.WaveFormat.BitsPerSample / 8;
int channels = bufferedWaveProvider.WaveFormat.Channels;
Expand Down
Loading