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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
uses: actions/checkout@v7

- name: Setup .NET 10
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to Chromatics are documented here.

## 4.3.31

- Added support for FFXIV patch 7.55.
- Lighting no longer stops for the rest of the session when a single game-data read fails. Chromatics now retries, and reconnects to the game if the failures persist.

## 4.3.29

- Added a Black layer type, available as both a base layer and a dynamic layer.
- Layers saved for a disabled or disconnected device can now be copied to another device.
- New setting under Settings → General: lower the RGB refresh rate while FFXIV is not running. With the setting off, your configured refresh rate now applies in every state.
- Fixed the update prompt showing the newest version's heading twice in its changelog.

## 4.3.24

- **New:** Nanoleaf smart-light support (Beta). Covers the panel family (Shapes, Canvas, Elements, Lines, Aurora) and other controllers that speak the Nanoleaf OpenAPI. Enable it from Settings → Device Providers and pair each controller with a one-time button press; effects render across the panels at their real physical positions. Layer assignments stay with their panels if you add or remove panels from the wall later. Essentials bulbs and strips are not supported.
Expand Down
2 changes: 1 addition & 1 deletion Chromatics.Tests/Chromatics.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Sharlayan" Version="9.1.2" />
<PackageReference Include="Sharlayan" Version="9.1.3" />
</ItemGroup>

</Project>
80 changes: 80 additions & 0 deletions Chromatics.Tests/Helpers/LayerCopierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Chromatics.Helpers;
using RGB.NET.Core;

namespace Chromatics.Tests.Helpers;

// Pins the default LedId mapping used when copying layers from a device
// that is disabled or no longer connected: the layer's own LedId set
// stands in for the missing device, so these rules decide where each key
// lands on the destination before the user touches the override dropdowns.
public class LayerCopierTests
{
[Fact]
public void KeyboardPair_MapsIdentity_OnlyWhereDestinationHasTheKey()
{
var used = new[] { LedId.Keyboard_A, LedId.Keyboard_B, LedId.Keyboard_NumLock };
var destIds = new List<LedId> { LedId.Keyboard_A, LedId.Keyboard_B }; // no numpad

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, destIds);

Assert.Equal(LedId.Keyboard_A, map[LedId.Keyboard_A]);
Assert.Equal(LedId.Keyboard_B, map[LedId.Keyboard_B]);
// Keyboard pairs never fall back positionally: a missing key is
// dropped so it can't land on an unrelated physical key.
Assert.False(map.ContainsKey(LedId.Keyboard_NumLock));
}

[Fact]
public void NonKeyboard_ExactIdMatchWins()
{
var used = new[] { LedId.Custom1, LedId.Custom2 };
var destIds = new List<LedId> { LedId.Custom1, LedId.Custom2, LedId.Custom3 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.LedStripe, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom1]);
Assert.Equal(LedId.Custom2, map[LedId.Custom2]);
}

[Fact]
public void NonKeyboard_OrdinalFallback_WhenIdsDoNotOverlap()
{
// Source painted Custom5..Custom7; destination only has Custom1..Custom3.
var used = new[] { LedId.Custom5, LedId.Custom6, LedId.Custom7 };
var destIds = new List<LedId> { LedId.Custom1, LedId.Custom2, LedId.Custom3 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.Mouse, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom5]);
Assert.Equal(LedId.Custom2, map[LedId.Custom6]);
Assert.Equal(LedId.Custom3, map[LedId.Custom7]);
}

[Fact]
public void NonKeyboard_SourceLargerThanDestination_DropsTheTail()
{
var used = new[] { LedId.Custom5, LedId.Custom6, LedId.Custom7 };
var destIds = new List<LedId> { LedId.Custom1 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.LedStripe, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom5]);
Assert.False(map.ContainsKey(LedId.Custom6));
Assert.False(map.ContainsKey(LedId.Custom7));
}

[Fact]
public void EmptyOrMissingInputs_ReturnEmptyMap()
{
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
Array.Empty<LedId>(), RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, new List<LedId> { LedId.Keyboard_A }));
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
null, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, new List<LedId>()));
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
new[] { LedId.Keyboard_A }, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, null));
}
}
100 changes: 100 additions & 0 deletions Chromatics.Tests/Helpers/UpdateNotesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using Chromatics.Helpers;

namespace Chromatics.Tests.Helpers;

// Pins the release-notes trimming behind the update dialog. Notes embedded
// by older publishes start with their own version heading and carry every
// older changelog section; the dialog adds its own heading per release, so
// rendering them raw doubled the version line and repeated old sections.
public class UpdateNotesTests
{
[Fact]
public void OldFormatNotes_ReduceToOwnBullets()
{
var notes = "## 4.3.26\n\n- New feature one.\n- Fix two.\n\n## 4.3.24\n\n- Old bullet.\n";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- New feature one.\n- Fix two.", trimmed.Replace("\r\n", "\n"));
}

[Fact]
public void NewFormatNotes_BulletsOnly_PassThroughUnchanged()
{
var notes = "- New feature one.\n- Fix two.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal(notes, trimmed.Replace("\r\n", "\n"));
}

[Theory]
[InlineData("## 4.3.26.0\n\n- Bullet.")]
[InlineData("## [4.3.26]\n\n- Bullet.")]
[InlineData("## v4.3.26\n\n- Bullet.")]
[InlineData("## 4.3.26 - 2026-07-07\n\n- Bullet.")]
public void HeadingVariants_AreAllStripped(string notes)
{
var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- Bullet.", trimmed);
}

[Fact]
public void DifferentVersionHeading_IsNotStripped_ButLaterSectionsAreCut()
{
// A mislabelled asset should not lose its first line; only the
// trailing sections get cut.
var notes = "- Bullet without heading.\n\n## 4.3.20\n\n- Old.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- Bullet without heading.", trimmed.Replace("\r\n", "\n"));
}

[Fact]
public void EmptyOrWhitespaceNotes_ReturnEmpty()
{
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection("", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(" ", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(null, "4.3.26"));

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2022)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2022)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2025)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2025)

Cannot convert null literal to non-nullable reference type.
}

[Fact]
public void VersionPrefix_DoesNotStripALongerVersionsHeading()
{
// "4.3.2" is a numeric prefix of "4.3.26" - the heading must
// survive so another release's bullets are never adopted.
var notes = "## 4.3.26\n\n- Bullet from 4.3.26.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.2");

Assert.StartsWith("## 4.3.26", trimmed);
Assert.Contains("- Bullet from 4.3.26.", trimmed);
}

[Fact]
public void WrongLeadingHeading_KeepsItsSection_InsteadOfWipingToEmpty()
{
// A mislabelled feed asset: the heading names a different version.
// The entry must keep that heading and its bullets; only sections
// after it get cut.
var notes = "## 4.3.30\n\n- Bullet A.\n\n## 4.3.29\n\n- Bullet B.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.29");

Assert.StartsWith("## 4.3.30", trimmed.Replace("\r\n", "\n"));
Assert.Contains("- Bullet A.", trimmed);
Assert.DoesNotContain("- Bullet B.", trimmed);
}

[Fact]
public void LeadingHorizontalRule_IsStripped()
{
var notes = "---\n\n- Bullet.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- Bullet.", trimmed);
}
}
6 changes: 3 additions & 3 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.24.0</Version>
<Version>4.3.31.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 @@ -97,7 +97,7 @@
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="6.1.3" />
<PackageReference Include="NLog" Version="6.1.4" />
<!-- RGB.NET.Core comes from a local DLL (Build Dependencies/RGB.NET/)
that carries the CHROMATICS-15 disposed-singleton fix. The
PackageReference stays so transitive references from the other
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.1.2" />
<PackageReference Include="Sharlayan" Version="9.1.3" />
</ItemGroup>

<ItemGroup>
Expand Down
99 changes: 77 additions & 22 deletions Chromatics/Core/GameController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static class GameController
// before a connection was ever established.
private static int activeProcessId = -1;
private static bool gameConnected;
private static int _consecutiveTickFaults;
private static bool gameSetup;
private static bool _isInGame;
private static bool _onTitle;
Expand Down Expand Up @@ -187,7 +188,7 @@ public static void Setup()
RGBController.RunStartupEffects();
Task.Run(() => GameConnectionLoop(_GameConnectionCancellationTokenSource.Token))
.ContinueWith(
t => Logger.WriteConsole(LoggerTypes.Error, $"GameConnectionLoop faulted: {t.Exception?.GetBaseException()?.Message}"),
t => Logger.WriteConsole(LoggerTypes.Error, $"GameConnectionLoop faulted: {t.Exception?.GetBaseException()}"),
TaskContinuationOptions.OnlyOnFaulted);
}

Expand Down Expand Up @@ -293,7 +294,11 @@ private static void StartGameLoop()
var loopToken = _GameLoopCancellationTokenSource.Token;
Task.Run(() => GameLoop(loopToken))
.ContinueWith(
t => Logger.WriteConsole(LoggerTypes.Error, $"GameLoop faulted: {t.Exception?.GetBaseException()?.Message}"),
// Full exception detail, not just the message - the
// CHROMATICS-1D report arrived stackless because this
// logged GetBaseException().Message alone, which made
// the fault undiagnosable.
t => Logger.WriteConsole(LoggerTypes.Error, $"GameLoop faulted: {t.Exception?.GetBaseException()}"),
TaskContinuationOptions.OnlyOnFaulted);
}
}
Expand Down Expand Up @@ -339,7 +344,7 @@ private static void StopGameLoop(bool reconnect = false)
RGBController.RunStartupEffects();
Task.Run(() => GameConnectionLoop(reconnectToken))
.ContinueWith(
t => Logger.WriteConsole(LoggerTypes.Error, $"GameConnectionLoop (reconnect) faulted: {t.Exception?.GetBaseException()?.Message}"),
t => Logger.WriteConsole(LoggerTypes.Error, $"GameConnectionLoop (reconnect) faulted: {t.Exception?.GetBaseException()}"),
TaskContinuationOptions.OnlyOnFaulted);
}
}
Expand All @@ -354,30 +359,76 @@ private static void SafeCancel(CancellationTokenSource cts)

private static async Task GameLoop(CancellationToken cancellationToken)
{
// Fresh loop, fresh strike count - the field is static, so a
// count carried over from a previous session would trip the
// persistent-failure fallback early on reconnect.
_consecutiveTickFaults = 0;

while (!cancellationToken.IsCancellationRequested && !_isShuttingDown)
{
if (IsGameRunning())
// One bad iteration must not kill lighting for the whole
// session. The try covers the WHOLE body - IsGameRunning's
// process enumeration and the disconnect branch included -
// because the CHROMATICS-1D fault escaped the task from
// outside GameProcessLayers (whose own catch now rethrows
// here so this is the single fault-policy point). GDI+
// reports many non-memory failures as OutOfMemoryException;
// that report hit exactly that shape with 25GB of RAM free.
// The first and final failures forward to Sentry with the
// full stack; interim retries only log locally. Ten
// consecutive failures means the fault is persistent: fall
// back to the disconnect path so the reconnect loop takes
// over cleanly instead of a dead task.
try
{
GameProcessLayers();
if (IsGameRunning())
{
GameProcessLayers();
_consecutiveTickFaults = 0;
}
else
{
gameConnected = false;
_isInGame = false;
_onTitle = false;
_consecutiveTickFaults = 0;

Logger.WriteConsole(LoggerTypes.FFXIV, @"Lost connection to FFXIV. Will attempt to reconnect.");

if (AppSettings.GetSettings().closeWithGame)
{
Logger.WriteConsole(LoggerTypes.FFXIV, "Closing Chromatics (Close with Game is enabled).");
OnGameExited?.Invoke();
}

if (!_isShuttingDown)
StopGameLoop(true);

break;
}
}
else
catch (Exception ex)
{
gameConnected = false;
_isInGame = false;
_onTitle = false;

Logger.WriteConsole(LoggerTypes.FFXIV, @"Lost connection to FFXIV. Will attempt to reconnect.");
_consecutiveTickFaults++;
bool givingUp = _consecutiveTickFaults >= 10;
Logger.WriteConsole(LoggerTypes.Error,
$"GameLoop tick failed ({_consecutiveTickFaults} consecutive): {ex}",
forwardToSentry: givingUp || _consecutiveTickFaults == 1);

if (AppSettings.GetSettings().closeWithGame)
if (givingUp)
{
Logger.WriteConsole(LoggerTypes.FFXIV, "Closing Chromatics (Close with Game is enabled).");
OnGameExited?.Invoke();
}
Logger.WriteConsole(LoggerTypes.Error,
"GameLoop is failing persistently; dropping the game connection to recover.");
_consecutiveTickFaults = 0;
gameConnected = false;
_isInGame = false;
_onTitle = false;

if (!_isShuttingDown)
StopGameLoop(true);
if (!_isShuttingDown)
StopGameLoop(true);

break;
break;
}
}

if (cancellationToken.IsCancellationRequested || _isShuttingDown)
Expand Down Expand Up @@ -750,11 +801,15 @@ private static void GameProcessLayers()
}
}
}
catch (Exception ex)
catch (Exception)
{
// Debug.WriteLine is [Conditional("DEBUG")], so the call compiles
// away in Release while still referencing `ex` for the analyzer.
Debug.WriteLine($"Exception: {ex.Message}");
// Rethrow so GameLoop's per-tick handler - the single fault
// policy point - counts, logs the full exception, and decides
// between retry and reconnect. Swallowing here hid every
// processing fault in Release builds (the old Debug.WriteLine
// compiles away), which is how CHROMATICS-1D class failures
// stayed invisible until one escaped and killed the task.
throw;
}


Expand Down
Loading
Loading