Skip to content

v4.1.11: PlayStation controller lighting (Beta) + cross-language status checks + effect-layer master toggle#184

Closed
logicallysynced wants to merge 28 commits into
masterfrom
chromatics-4.0.x
Closed

v4.1.11: PlayStation controller lighting (Beta) + cross-language status checks + effect-layer master toggle#184
logicallysynced wants to merge 28 commits into
masterfrom
chromatics-4.0.x

Conversation

@logicallysynced
Copy link
Copy Markdown
Owner

@logicallysynced logicallysynced commented May 4, 2026

Summary

Rolling release covering v4.0.154 → v4.1.10. Headline feature is the new PlayStation controller lighting provider; the rest is the cross-language status work + housekeeping that landed alongside.

v4.1.x — PlayStation DS4 / DualSense lighting (Beta)

A new custom RGB.NET device provider for Sony's PlayStation controllers — DualShock 4 (PS4) and DualSense / DualSense Edge (PS5) — talking raw HID, no third-party drivers, no SignalRGB / DS4Windows / HidHide dependency. Lighting only; controller input continues through Windows' HID stack normally.

Hardware coverage: DS4 v1 (0x05C4), DS4 v2 (0x09CC), DS4 wireless adapter (0x0BA0), DualSense (0x0CE6), DualSense Edge (0x0DF2). Both USB and Bluetooth supported (BT carries trailing little-endian CRC32 over 0xA2 || payload, mirroring Linux's hid-playstation.c).

LEDs:

  • DualShock 4: 1 LED (lightbar) — Custom1.
  • DualSense: 6 LEDs — Custom1 lightbar, Custom2..Custom6 player indicators 1..5 (left→right). Mic-mute LED is intentionally NOT exposed — left under firmware control so it keeps tracking the hardware mic-mute toggle (orange when muted, off when unmuted).

Hot-plug: HidSharp DeviceList.Local.Changed drives a debounced reconcile (1500ms — let Windows finish enumerating after USB connect). On hot-add the device joins the surface, brightness corrections are attached, and if the startup animation is currently playing it's rebuilt to include the new controller. On unplug the queue is suspended immediately via a per-frame IsDevicePathAlive check + a _confirmedDisconnected flag, then cleaned up by the debounced Reconcile.

The HidStream.Write path was bypassed entirely in favour of a direct Win32 WriteFile P/Invoke (HidRawWriter) — WriteFile returns BOOL on failure instead of throwing, eliminating the IOException("device is not connected") first-chance break that fired on every disconnect even with a perfect pre-check (sub-millisecond race between check and write is unavoidable with throwing APIs). Each controller now has two kernel handles: HidSharp's for TryOpen exclusive-access detection + descriptor reads, ours for the actual lighting writes. Sony HID gamepads accept shared handles, so coexistence with Steam / DS4Windows / games is unchanged.

Brightness: global slider + per-device slider both apply automatically — RGB.NET's ColorCorrections chain runs on every painted color before it reaches our queue.

Identity: DeviceName includes the controller's serial (or a stable hash of DevicePath when the serial descriptor isn't readable, e.g. older DS4 v1) so mappings persist correctly across restarts and disambiguate two same-model controllers.

Beta tag in Settings + first-run wizard. Disabled by default. Bundles a [BETA] tooltip in Settings → Device Providers and a "PlayStation (Beta)" tile in the first-run wizard.

v4.0.157 — Cross-language status checks + first-run language picker

  • Bumped Sharlayan 9.0.24-prerelease.35 → 9.0.29-prerelease.38. Picks up the new StatusItem.StatusNameEnglish field (always-English regardless of Configuration.GameLanguage), the resolver fix for stale status slots, and zero-Stacks for non-stacking statuses.
  • All 5 status-name dispatch sites (JobGaugeA: Iron Will / Summon Seraph / Grit / Royal Guard; JobGaugeB: Defiance) now compare against StatusNameEnglish. Today they work only because Chromatics pins GameLanguage = English; this insulates them.
  • Language picker on the first-run dialog. Source is the existing Language enum; live re-translation via LocalizationService.SetLanguage bumping Version.

v4.0.154 → v4.0.156 housekeeping

  • Bump Sharlayan to 9.0.24-prerelease.35 → 9.0.29-prerelease.38.
  • Sentry triage: UnobservedTaskException no longer force-crashes for benign socket aborts, BeforeSend drops them, RGB.NET surface/device init errors no longer auto-forward to Sentry.
  • Layer save retries File.Replace with backoff (50/100/200/400/800 ms) on transient IOException / UnauthorizedAccessException.
  • Dependabot rollups: Avalonia 12.0.2, Microsoft.NET.Test.Sdk 18.5.1, coverlet.collector 10.0.0, Sentry/Sentry.Profiling 6.5.0, NLog 6.1.3.
  • Loose Sharlayan DLL replaced with NuGet package reference + decorator harness csproj cleanup.

Test plan

  • ./test.cmd — 130/130 xUnit pass on each commit.
  • DS4 USB: connect/disconnect cycle no longer throws IOException.
  • DS5 USB: lightbar + player indicators paint correctly; mic-mute LED tracks hardware button state (firmware-driven).
  • DS5 BT: lightbar paints correctly with CRC32 (smoke test on real hardware).
  • Hot-plug during startup animation: new controller joins the rainbow within ~1.5s.
  • Verify global / per-device brightness sliders apply to PlayStation controllers.

🤖 Generated with Claude Code

- Bump Sharlayan 9.0.26-prerelease.37 → 9.0.29-prerelease.38. Picks up StatusItem.StatusNameEnglish (always-English regardless of Configuration.GameLanguage), the resolver fix for stale status slots (BattleChara.StatusManager._status / NativePartyMember.StatusManager._status are now resolved via FieldOffsetReader), and zero-Stacks for non-stacking statuses (MaxStacks <= 1) so consumers don't see Param garbage.
- Switch the 5 status-name dispatch sites (JobGaugeA: Iron Will, Summon Seraph, Grit, Royal Guard; JobGaugeB: Defiance) from StatusName to StatusNameEnglish. Today they work because Chromatics pins GameLanguage = English; this insulates the comparisons if that ever changes.
- FirstRunDialog gets a top-right language picker. Source is the existing Language enum (same as Settings → Language), persists to settings.systemLanguage, and calls LocalizationService.SetLanguage which bumps Version — all {loc:Tr Key='...'} bindings in the dialog re-render in the chosen language live, no relaunch.
- README: clarify that only the global FFXIV client is supported (KR/CN out of scope for FFXIVClientStructs/Sharlayan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@logicallysynced logicallysynced self-assigned this May 4, 2026
logicallysynced and others added 16 commits May 7, 2026 21:35
…T) (v4.0.158)

New custom RGB.NET device provider for Sony PlayStation controllers, talking raw HID via HidSharp — no third-party drivers, no SignalRGB / DS4Windows / HidHide dependency. Lighting only; controller input continues to flow through Windows' HID stack to games normally because Sony gamepads accept shared output writes by default.

Hardware coverage:
- DualShock 4 v1 (PID 0x05C4), DualShock 4 v2 (0x09CC), DualShock 4 wireless adapter (0x0BA0): single RGB lightbar.
- DualSense (0x0CE6) and DualSense Edge (0x0DF2): RGB lightbar + 5 monochrome player-indicator LEDs + mic-mute LED, exposed as Custom1..Custom7 in RGB.NET.

Both transports supported. Transport is auto-detected from the HID device's max output report length (DS4 USB=32 / BT=78, DS5 USB=64 / BT=78). Bluetooth output reports carry a trailing little-endian CRC32 (poly 0xEDB88320, init 0xFFFFFFFF, XOR-out 0xFFFFFFFF, prepended with the 0xA2 output-report seed byte) — algorithm and byte layouts mirror the Linux hid-playstation.c driver verbatim.

DualSense reports also set valid_flag1 bits to enable host control of the lightbar / player indicators / mic-mute LED, plus a one-shot LIGHTBAR_SETUP_CONTROL_ENABLE on the first report after open to release the firmware boot animation.

Last-writer-wins coexistence at 30Hz dominates Steam Input's intermittent profile-change writes. Where another tool holds exclusive HID access (DS4Windows / reWASD with exclusive mode enabled) the open fails — provider logs a clear human-readable hint at Error tier (Sentry-suppressed since this is a user-environment condition, not a bug). HidHide-hidden controllers simply don't appear in HID enumeration; provider emits a "no controllers detected" hint in that case so the cause is discoverable.

Wiring:
- New SettingsModel.devicePlayStationEnabled (default off).
- New tile in FirstRunDialog (PlayStation, between OpenRGB and Continue).
- New row in Settings → Device Providers list with the same enable/disable plumbing as the other providers.
- Provider registered in RGBController.SetupRGBDevices behind the settings flag.
- "PlayStation" locale key added to en.json + all 6 non-English locales (brand name; same string everywhere).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…0.159)

- Brightness: confirmed the global + per-device brightness corrections flow automatically. AbstractRGBDeviceProvider.AddDevice fires DevicesChanged.Added → RGBController.DevicesChanged attaches GlobalBrightnessCorrection + PerDeviceBrightnessCorrection to device.ColorCorrections, which RGB.NET applies to every Color before it hits our UpdateQueue. Monochrome player-indicator / mic LEDs naturally fade to off at 0% brightness via the same mechanism.

- [BETA] tag in Settings tooltip + "PlayStation (Beta)" in the first-run wizard tile, matching the existing Hue convention. New "PlayStation (Beta)" locale key added to en.json and all 6 non-English locales.

- Hot-plug: subscribes to HidSharp.DeviceList.Local.Changed once on provider init. Each PnP event schedules a debounced (500ms) reconcile that compares enumerated Sony controllers (by DevicePath) against the open set, opens new ones via the existing TryOpenAndCreateDevice path, and calls AddDevice (raises DevicesChanged.Added → brightness corrections attached automatically). Removed devices are detected by DevicePath disappearance from enumeration.

- RemoveDevice override: ensures the HidStream is disposed and a final off-frame is sent (best-effort) whether removal originates from hot-plug, provider unload, or external teardown. Cached _openStreams + _devicePaths state is cleaned up under a lock; the lock is dropped before calling base.RemoveDevice so DevicesChanged handlers can't deadlock against us.

- Dispose now properly unsubscribes from DeviceList.Local.Changed and walks open devices through RemoveDevice rather than just disposing the streams, so each device gets its proper teardown sequence (off-frame, DevicesChanged.Removed, brightness-correction detach).

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlayStationDeviceInfo.DeviceName now includes the controller's serial number when available, e.g. "DualSense (USB) [AB1234CD5678]". DeviceName feeds Chromatics's DeviceHelper.GenerateDeviceGuid (the persistence key for layer mappings, Mappings-tab layout overrides, per-device brightness, etc.), so this gives every physical controller a stable identity that survives restarts and disambiguates two same-model controllers without depending on enumeration order.

Sony's HID serial descriptor is populated for both transports — USB serial string on USB, controller MAC on BT — so this works uniformly across transports. Trade-off: the same controller switching between USB and BT still produces a different GUID (transport tag is preserved in the name so the device list is unambiguous when both are connected). Acceptable for v1; a transport-agnostic identity would hide legitimate dual-presence cases.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entity (v4.0.161)

Reorder the metadata queries (GetSerialNumber, GetMaxOutputReportLength) to run BEFORE we open the HidStream. Some HID gamepads — DS4 v1 in particular — reject the second concurrent kernel handle that HidSharp opens internally to read the device descriptor, causing GetSerialNumber to throw HidSharp.Exceptions.DeviceIOException ("Failed to get info."). Querying first while we don't yet hold a stream avoids the conflict.

When the serial descriptor still isn't readable (rare even after the reorder — e.g. mid-enumeration BT pairing), fall back to a 12-char SHA-1 prefix of HidDevice.DevicePath. DevicePath on Windows includes the controller's instance ID, which is stable across app restarts for the same physical+slot combination, so the GUID-from-name persistence keys still survive a restart for these controllers.

GetMaxOutputReportLength now also runs pre-open with a post-open retry; same rationale (descriptor read can race with the main handle on some hardware).

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on DS4 (v4.0.162)

Root cause of the user-reported "HidSharp.Exceptions.DeviceIOException: Failed to get info." on DS4 USB connect: HidSharp's RequiresGetInfo opens a *separate* read-info handle via TryOpenToGetInfo(_path, ...) for any descriptor flag not already cached. On DS4 v1 — and on any controller whose descriptor-query handle is being blocked (Steam Input keeping the device busy, partial driver init, certain power states) — that secondary handle open fails and HidSharp throws DeviceIOException. Three call sites trigger this: GetSerialNumber, GetMaxOutputReportLength, and TryOpen itself.

The previous fix (v4.0.161) was the wrong direction — pre-calling GetSerialNumber and GetMaxOutputReportLength added two more throw sites instead of removing them. With "Break on user-handled" enabled in VS, the IDE pauses on every throw even though the catch handles it.

Real fix: never call GetSerialNumber. Identity always comes from ShortHashOf(DevicePath) — DevicePath is a cheap property (no descriptor query, no throw) and includes the Windows instance ID so it's stable across restarts for the same physical+slot pairing. Only TryOpen remains as a possible throw site; its catch handler now surfaces a friendly "another tool may be blocking access" hint covering the Steam / DS4Windows / reWASD / HidHide cases. After a successful TryOpen, GetMaxOutputReportLength is free because TryOpen's OpenDeviceDirectly primes the ReportInfo cache as a side effect.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PlayStation provider bugs from real-hardware testing.

Bug 1 — effects don't apply to hot-added controllers. Root cause: AbstractRGBDeviceProvider.AddDevice only adds to InternalDevices and fires DevicesChanged; it does NOT call surface.Attach. Startup-loaded devices get attached because LoadDeviceProvider iterates provider.Devices and calls surface.Attach on each, but hot-plugged devices skip that path entirely. Result: the device exists on the provider but is invisible to RGB.NET's surface, UpdateQueue is never ticked, no colours render.

Fix at the RGBController level (benefits any future hot-plug-capable provider, not just PlayStation): in DevicesChanged.Added, also call surface.Attach(device); idempotent for the startup path because the surface already contains the device. Mirror in DevicesChanged.Removed with surface.Detach so RGB.NET stops trying to update gone devices.

Bug 2 — IOException ("device is not connected") at line 138 of DualShock4UpdateQueue.cs / DualSenseUpdateQueue.cs on disconnect. Shutdown's final off-frame write throws when the device is already gone. The catch handles it functionally but the IDE breaks on first-chance. Fix: pass sendOffFrame=false from Reconcile's confirmed-disconnect path. Track confirmed-disconnected devices in a small HashSet on the provider so the RemoveDevice override can decide whether the off-frame attempt is worthwhile. Voluntary teardowns (provider unload, app exit) still send the off-frame so the controller's lightbar returns to a clean state.

Bumped from 4.0.162 → 4.1.0 (minor) per user request — PlayStation provider is a substantial enough addition to warrant the bump. CHANGELOG entries for 4.0.158–4.0.162 consolidated into 4.1.0 since they all flesh out the same feature.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(v4.1.1)

The 4.1.0 surface.Attach hop in DevicesChanged.Added/Removed broke startup for any provider whose Initialize raises DevicesChanged for each device — Razer, Logitech, and others. Reason: SurfaceExtensions.Load() is `provider.Initialize(); surface.Attach(provider.Devices);` — during Initialize, AddDevice fires DevicesChanged for each device. Our handler attached at that moment (Surface == null, Contains == false → Attach succeeds), then Load's later surface.Attach pass found Surface != null and threw RGBSurfaceException("already attached").

Discriminator is IRGBDeviceProvider.IsInitialized:
- false during Initialize (startup) — Load owns the attach, we skip
- true after Initialize completes (hot-plug) — Load won't touch the device again, we own the attach

Same gating applied to the Removed branch so UnloadDeviceProvider's "detach all then Reset()" sequence doesn't fire a duplicate Detach via the bookkeeping pass.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug A — hot-plugged controllers never received Update() ticks. AbstractRGBDeviceProvider.Initialize() runs trigger.Start() once at the end of initial enumeration, iterating UpdateTriggerMapping. Our DeviceUpdateTrigger is created lazily by GetUpdateTrigger() inside the queue's constructor, so:

  - If startup found a controller: trigger is created during Initialize, then Start() in the post-load loop. Lighting works.
  - If startup found NOTHING: UpdateTriggerMapping is empty during the post-load Start() loop. The trigger isn't created until the first hot-plug calls GetUpdateTrigger() — but by then the Start() pass is long gone. Trigger sits idle, queue.OnUpdate never fires, lightbar stays on firmware default. Matches the user's "works if connected before launch, stays solid blue if hot-plugged" report exactly.

Fix: explicit Start() call after AddDevice in Reconcile. Idempotent (DeviceUpdateTrigger.Start checks IsRunning).

Bug B — IOException "A device which does not exist was specified." on hot-connect. HidSharp.TryOpen can succeed against a partially-enumerated HID device — the kernel handle is valid but the driver hasn't finished setting up the IO endpoints, so the first Write fails. Bumped HotplugDebounceMs from 500 to 1500 to let Windows settle. User sees a 1.5s lag once on connect (acceptable for a controller); subsequent writes hit the now-fully-enumerated device cleanly.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dispose (v4.1.3)

Bug A — controller stays on firmware default during startup animation. RunStartupEffects builds one ListLedGroup per device on the surface AT THE MOMENT IT'S CALLED. Hot-plugged devices arrive after that and are never added to those groups, so the startup rainbow paints every other device but skips the new controller — which then sits on the firmware-default solid blue until the animation tag is stopped. Fix: in RGBController.DevicesChanged.Added, when isHotPlug=true AND the "startup" tag still has live groups, call RunStartupEffects() again. RunStartupEffects already does StopTaggedEffects("startup") at the top so the rebuild is clean. Gated on HasActiveTaggedEffect("startup") so we don't accidentally restart the startup animation over a running game (we'd nuke the user's game-driven effects otherwise).

Bug B — IOException at DualShock4UpdateQueue.cs line 146 / DualSenseUpdateQueue.cs line 187 on app close. Shutdown's final off-frame Write throws "Operation failed early: A device which does not exist was specified" because Windows can invalidate the HID kernel handle during process exit even while the controller is physically still connected. Catch is already there so the app doesn't crash, but the IDE breaks on first chance which is what the user actually sees. Fix: provider tracks a _disposing flag set at the top of Dispose; RemoveDevice now skips the off-frame write when _disposing OR when Reconcile already confirmed the device is gone. The firmware resets the lightbar on its own when the OS closes the handle, so the visual difference is negligible.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction (these are PlayStation feature troubleshooting iterations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DualShock4UpdateQueue.Update / DualSenseUpdateQueue.Update now set _disposed=true after the first failed Write, so the next 30Hz tick short-circuits at the existing `if (_disposed) return true` gate instead of bombarding HidStream.Write with doomed calls. Without this, unplugging a controller produced a first-chance IOException ~45 times in the 1.5s window between disconnect and Reconcile cleanup — the user could not "continue" past the debugger break because the next frame fired the same throw.

Self-dispose is safe because:
  - HidStream.Write failure on a Sony HID gamepad means the kernel handle is dead (USB unplug / BT unpair / Windows invalidated it). Subsequent writes will keep failing.
  - Reconcile catches the disconnect within HotplugDebounceMs (1500ms) and calls RemoveDevice, which invokes Shutdown (idempotent, _disposed=true is a no-op) and disposes the stream.
  - If the controller comes back, HidSharp enumerates a NEW HidDevice with a fresh handle, Reconcile creates a fresh queue+device, no stale state survives.

The Shutdown path's `if (_disposed) return;` early-exit also short-circuits cleanly since _disposed is now set; the final off-frame write is skipped (which we want — same reason as the _disposing-flag suppression in v4.1.3).

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…irely (v4.1.5)

v4.1.4 stopped the per-frame BOMBARDMENT of IOExceptions on unplug, but the FIRST write of the disconnect window still threw before the queue's catch block self-disposed it. The user wanted zero exceptions, not just one.

Fix: split OnHidDeviceListChanged into two passes.

  Pass 1 (immediate, no debounce): walk our open set against the live HID enumeration. Any tracked device whose DevicePath is no longer enumerated is suspended via the new SuspendWrites() method on DualShock4Device / DualSenseDevice / their UpdateQueues. SuspendWrites just sets _disposed=true on the queue — lighter than Shutdown (no off-frame attempt, no other state mutation, idempotent). The next 30Hz trigger tick hits the existing `if (_disposed) return true` short-circuit at the top of Update; HidStream.Write is never reached, no IOException is thrown.

  Pass 2 (debounced 1500ms): the existing Reconcile, unchanged. Still owns the slow side: full RemoveDevice cleanup, stream disposal, surface.Detach, and the connect-side device opens that need the debounce to let Windows finish enumerating.

The in-Update self-disposal added in v4.1.4 stays as a defensive backstop for pathological cases (HidSharp.DeviceList.Changed fires but the enumeration query returns stale data, etc.) — those paths still see one exception, but the normal unplug flow now sees zero.

Pass 1 also seeds _confirmedDisconnected so when Pass 2 catches up, RemoveDevice's `sendOffFrame = !wasConfirmedGone && !_disposing` correctly resolves to false and the cleanup is silent end-to-end.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (v4.1.6)

v4.1.5's Pass 1 sets _disposed=true the moment HidSharp raises DeviceList.Changed, but Changed fires on a notify thread (WinHidManager.cs DeviceMonitorEventThread) that's pulsed from the message-pump thread's WM_DEVICECHANGE handler. Under load (Avalonia rendering, GC, etc.) the notify thread can be scheduled later than our 30Hz trigger's next tick, so a Write fires against an already-invalidated handle and HidSharp throws "Operation failed early: The device is not connected."

This is now closed by a wait-free per-frame pre-check.

Provider: new static volatile HashSet<string> _alivePathsSnapshot. SuspendDeadDevices replaces it atomically with the fresh enumeration result every time it runs (initial LoadDevices, every PnP event, every Reconcile addition). Public IsDevicePathAlive(string) returns whether a path is in the current snapshot — single hashset lookup, no allocation, no lock.

UpdateQueues: each queue now stores its devicePath at construction and consults IsDevicePathAlive at the top of Update — BEFORE the lock + Write block. If the snapshot says the device is gone, the queue self-disposes and returns without touching HidStream. The remaining "trigger fires concurrently with PnP before SuspendDeadDevices runs" race is sub-millisecond and only matters for the very first tick after unplug; the in-Update catch from v4.1.4 is still there as a backstop for that corner case.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v4.1.6's _alivePathsSnapshot is replaced asynchronously by SuspendDeadDevices, which runs on HidSharp's notify thread (pulsed by WinHidManager.DeviceMonitorWindowProc on the message-pump thread). The notify thread can be scheduled later than our 30Hz trigger's next tick, so for ~80% of unplugs the trigger reads a stale snapshot, IsDevicePathAlive returns true, and HidStream.Write throws.

HidSharp's GetHidDevices() is itself live: WinHidManager.GetHidDeviceKeys() is gated on a notifyObject that's mutated SYNCHRONOUSLY in DeviceMonitorWindowProc when WM_DEVICECHANGE arrives — i.e. the cache is invalidated on the message-pump thread, BEFORE the notify thread is even pulsed. So a direct GetHidDevices() call from the trigger thread sees the unplug ahead of any DeviceList.Changed subscriber.

IsDevicePathAlive now does that direct call (filtered to the Sony VID, single iteration with ordinal-ignore-case path compare). Cost is a SetupDi-class enumeration on the tick immediately after a PnP event (~1ms), then cached lookups for subsequent ticks (sub-microsecond). At 30Hz under normal conditions this is negligible.

The previous _alivePathsSnapshot is left in place for SuspendDeadDevices' "immediately suspend dead queues" path, which still helps when ticks are infrequent. Belt-and-braces.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-checking + snapshot refresh fundamentally cannot eliminate the unplug IOException — there's always a sub-millisecond race where the device disconnects after the check passes but before HidStream.Write completes, and HidStream.Write throws synchronously on any failure (including a mid-write hardware disappearance). The catch handles it functionally, but the throw itself triggers a debugger first-chance break, which is what the user sees.

The only way to eliminate the throw is to use a Win32 API that returns status instead of throwing. WriteFile via P/Invoke does exactly that — returns BOOL, false on any failure (handle invalid, ERROR_NO_SUCH_DEVICE, ERROR_DEVICE_NOT_CONNECTED, partial write, etc.). No exception is ever raised, regardless of when the device dies.

New HidRawWriter opens its own kernel handle alongside HidSharp's via CreateFileW with shared read/write access (Sony HID gamepads support shared handles, so coexisting with HidSharp's handle is fine). Each UpdateQueue now writes via _writer.TryWrite(_buffer) instead of _stream.Write(_buffer); on a false return, the queue self-disposes the same way it did on the in-Update catch — but no IOException is ever thrown.

HidStream is still used for TryOpen (exclusive-access detection: DS4Windows / reWASD with exclusive mode enabled fail at TryOpen with UnauthorizedAccessException, which we surface as a friendly log message) and for descriptor info in transport detection. Two handles per controller is the trade-off; cost is negligible.

The previous defensive layers stay in place: per-frame IsDevicePathAlive pre-check (now skips the WriteFile call entirely when enumeration shows the device gone, saving even the BOOL=false round-trip), Reconcile + SuspendDeadDevices for queue cleanup, _confirmedDisconnected for off-frame skipping in RemoveDevice. With WriteFile replacing HidStream.Write, the throw is gone end-to-end.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DualSense's mic-mute LED has firmware-driven default behaviour: tap the mute button → firmware mutes the microphone AND lights the orange LED. Tap again → unmuted, LED off. Setting valid_flag1's MIC_MUTE_LED_CONTROL_ENABLE bit (0x01) tells the controller "host owns this LED" and the firmware uses our mute_button_led byte instead of its own logic. Since we drove that byte to 0 unless something was mapped to Custom7, the LED stayed dark even when the user muted via the button — the audio still muted (button behaviour is firmware-level regardless of host) but the visual feedback was silently suppressed.

Restoring default behaviour:
- ValidFlag1_All no longer ORs in MIC_MUTE_LED_CONTROL_ENABLE.
- mute_button_led byte (offset 8 in the common block) left at 0 from Array.Clear; firmware ignores it now that the valid bit isn't set.
- BuildReport signature drops the micMute parameter.
- DualSenseUpdateQueue.Update no longer collects the LedId.Custom7 case from the dataset walk.
- DualSenseDevice.InitializeLayout no longer adds the Custom7 LED. DS5 device is now 6 LEDs (lightbar + 5 player indicators) instead of 7.

The mute BUTTON itself remains firmware-driven — host activity never had any influence over the audio mute state. Only the LED visual was affected.

Existing user mappings to Custom7 on a DualSense will silently no-op since the LED no longer exists on the device. Mapping-tab UI will redraw with 6 LEDs after a reload.

130/130 xUnit pass. CHANGELOG intentionally not updated per user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sentry + Sentry.Profiling 6.4.1 → 6.5.0 (Dependabot PR #187). Memory-leak fix for filtered Activities + OTLP support.
- NLog 6.1.2 → 6.1.3 (Dependabot PR #185).
- Bumped Chromatics 4.1.9 → 4.1.10. CHANGELOG now lists the dependency bumps under the consolidated 4.1.10 entry.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@logicallysynced logicallysynced changed the title v4.0.157: cross-language status checks + first-run language picker + Sharlayan 9.0.29 v4.1.10: PlayStation controller lighting (Beta) + cross-language status checks + Dependabot rollup May 7, 2026
The Effect Layer row's checkbox in the Mappings tab previously gated only the effect-layer-rooted processors (raid effects, duty bell, damage flash, cutscene). Users intuitively expect it to be a master "effects off" switch covering ALL game-state-driven painting on the device — base layers (Reactive Weather, Job Classes, Battle Stance, AudioVisualizer), dynamic layers (HP tracker, target highlights, key bindings, …), and overlays (raid effect, raid highlight).

Implementation: single chokepoint in GameController.Update. Per-frame snapshot of devices whose EffectLayer.Enabled is false, then a continue-with-cleanup at the top of each layer iteration that detaches live groups and calls the raid-overlay / raid-highlight CleanupLayer paths. Processors rebuild from scratch via their existing requestUpdate / live-group detection when the user re-enables. New MappingLayers.IsDeviceEffectsEnabled helper exposes the state for any other callers that need it.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@logicallysynced logicallysynced changed the title v4.1.10: PlayStation controller lighting (Beta) + cross-language status checks + Dependabot rollup v4.1.11: PlayStation controller lighting (Beta) + cross-language status checks + effect-layer master toggle May 8, 2026
logicallysynced and others added 9 commits May 8, 2026 18:37
v4.1.11's EffectLayer toggle gated layer dispatch in GameController.Update, but layer dispatch isn't the only paint source. Tagged effects (startup animation, title screen) and the raid-effect overlay attach ListLedGroups directly to RGB.NET's surface and bypass the layer system entirely — so they kept painting on devices whose effect-layer was unticked.

Fix: new DeviceEffectsMuteCorrection : IColorCorrection lives on each device's ColorCorrections chain. IColorCorrection runs as the FINAL transformation on every Color before it reaches the device's UpdateQueue, so muting here covers ALL paint sources uniformly — layer dispatch, tagged effects, raid overlay, anything attached directly to the surface, all zero out together.

Wiring:
- AttachPerDeviceMute companion to AttachPerDeviceBrightness, called from DevicesChanged.Added.
- _perDeviceMute ConcurrentDictionary keyed by device GUID.
- Surface_Updating refreshes IsMuted from MappingLayers.IsDeviceEffectsEnabled once per frame (cheap — single dict iteration + one IsDeviceEffectsEnabled lookup per active device, which already walks the layer dictionary in O(N)).

The v4.1.11 GameController.Update gate stays in place — it stops layer processors from accumulating live-group state for muted devices and saves the CPU/allocation work. The mute correction is the visual chokepoint that catches everything else.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v4.1.11 + v4.1.12 overreached — they suppressed ALL paint sources for muted devices, including base-layer choices (Static colour, Job Classes, the static part of Reactive Weather, Screen Capture) and dynamic trackers (HP, Target, Key Bindings, …). The intended scope is the items on the Effects tab only: Raid Effects, Duty Finder Bell, Damage Flash, Cutscenes, Vegas Mode, Startup Animation, Title Screen, and the animated Reactive Weather decorators.

Reverts:
- GameController.Update no longer gates layer dispatch on a device-level effects flag.
- DeviceEffectsMuteCorrection IColorCorrection deleted; per-device mute dictionary + Surface_Updating refresh removed.

Implementation:
- Each effect-class processor (DutyFinderBell, DamageFlash, CutsceneAnimation, RaidEffectProcessor, RaidEffectHighlightProcessor, GoldSaucerVegas) ANDs its existing skip-condition with `MappingLayers.IsDeviceEffectsEnabled(layer.deviceGuid)`. When the user unticks effects for the device, processors detach overlays / dispose models / return early on the next tick — the same code path they already use when the global Effects-tab toggle is off.
- Reactive-weather animations (the decorator/storm overlays) ANDs `effect_reactiveweather` with the per-device flag in BOTH the BaseLayer/ReactiveWeather processor (decorator side) and DynamicLayer/ReactiveWeatherHighlight (overlay side). The static weather colour painting is unaffected — that's user base-layer choice, not an effect.
- Tagged effects (RunStartupEffects, GameController.BuildTitleScreenAnimation) skip devices whose effects are disabled at attach time. New RGBController.GetDeviceGuid(IRGBDevice) helper does the reverse lookup against the _devices dict.
- LayerItemViewModel.OnIsEnabledChanged calls new RGBController.RebuildActiveTaggedEffects() when the toggled layer is the EffectLayer — it re-fires startup / title-screen if either is currently active so the per-device filter takes effect immediately mid-animation.

Base layers and dynamic layers untouched.

130/130 xUnit pass. CHANGELOG bullet rewritten to reflect the narrower (correct) scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e repaint (v4.1.14)

v4.1.13 narrowed the toggle scope correctly but the disable transition was incomplete: each effect-class processor's "disabled" early-return left its ListLedGroup attached to the surface with its last brush state, sitting at high ZIndex above the base / dynamic painting. Visual symptom on a keyboard with Reactive Weather base: the keyboard "froze" on the effect's last frame, obscuring HP-tracker and other dynamics. On other devices: the effect/decorator visibly froze in place instead of cleanly reverting to base/dynamic.

Two pieces:

1. EffectLayer case in GameController.Update gains the same `if (layer.requestUpdate) { detach live groups }` block that BaseLayer and DynamicLayer already had. Effect-class processors store their groups in RGBController.GetLiveLayerGroups()[layerID] just like the others, so this generic detach covers DutyFinderBell, DamageFlash, CutsceneAnimation, GoldSaucerVegas uniformly. Raid effect / raid highlight overlays already self-detach via DetachOverlay in their disabled paths — no change needed there.

2. New MappingLayers.MarkDeviceLayersForUpdate(Guid) stamps requestUpdate=true on every layer for the device. LayerItemViewModel.OnIsEnabledChanged calls it when the toggled layer is the EffectLayer. Next frame, the per-layer-type cleanup blocks fire for every layer on that device:
   - Base layer: groups detached → base processor repaints (so reactive weather base colour, etc. refreshes from scratch)
   - Dynamic layer: groups detached → dynamic processor repaints (HP tracker, key bindings, etc. become visible again)
   - Effect layer: groups detached → effect processors short-circuit on the IsDeviceEffectsEnabled gate, no repaint, surface clean

Re-enabling the toggle goes through the same path, so dynamics and effects all rebuild cleanly.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e repaint (v4.1.14)

v4.1.13's per-device gate stopped FUTURE effect frames from rendering, but didn't tear down what was already painted, and accidentally smeared its cleanup across every device when the toggle flipped. Two distinct bugs.

Bug 1 — Cross-device contamination.
GoldSaucerVegas's per-device gate called RGBController.SetBaseLayerEffect(false) and RGBController.ResetLayerGroups() — both of which are GLOBAL teardowns. The pre-existing vegas-mode-off path uses them legitimately because vegas-mode is itself a global setting, but our per-device case must never touch other devices' state. Also, RebuildActiveTaggedEffects called the full-rig RunStartupEffects / BuildTitleScreenAnimation rebuild, which detached every device's tagged group via StopTaggedEffects("startup") and reset their animation phase even though only one device's toggle changed.

Fix: GoldSaucerVegas's per-device gate now only disposes the local model's gradient decorators — no global side effects. Tagged-effect reconciliation moves to a new per-device path:

- _taggedEffectsByDevice<string, Dictionary<Guid, ListLedGroup>> tracks each tagged group's source device.
- RegisterTaggedEffect overload accepts a deviceGuid; RunStartupEffects + BuildTitleScreenAnimation use it.
- SyncTaggedEffectsForDevice(Guid) detaches OR adds just one device's group depending on its toggle state. Other devices' groups stay attached and keep their animation phase. Replaces RebuildActiveTaggedEffects.
- Per-device builders BuildStartupEffectForDevice / BuildTitleEffectForDeviceInternal lifted out so the sync path can build a single device's group without re-iterating the whole rig.

Bug 2 — Effect ledgroups freeze instead of releasing.
Effect-class processors gated by the new per-device flag would just early-return on the next tick, but the ledgroups they had already attached stayed on the surface with their last brush evaluation. With the rainbow / weather decorator no longer driving them they sat frozen, obscuring the base + dynamic layers underneath.

Fix: LayerItemViewModel.OnIsEnabledChanged now also calls MappingLayers.MarkDeviceLayersForUpdate(deviceGuid) when the EffectLayer row toggles. That stamps requestUpdate=true on every layer (base / dynamic / effect) for the device. GameController.Update's existing cleanup blocks pick that up next frame and detach the live ledgroups for those layers; processors then rebuild from a clean state on the same frame, so base + dynamic re-paint correctly and stale effect groups drop off the surface.

130/130 xUnit pass. CHANGELOG unchanged — bullet wording from v4.1.13 still describes the intended scope correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eWeather (v4.1.15)

ReactiveWeatherHighlight had two paths reading the "are weather animations active" flag, and they were getting different answers when the per-device EffectLayer toggle was used:

  - Process() at line 63 ANDs the global effect_reactiveweather flag with IsDeviceEffectsEnabled(deviceGuid). That flag drives the redraw-transition detection at line 119 (correctly fires a repaint when the per-device toggle flips).

  - SetReactiveWeather() at line 147 was re-reading EffectsSettings standalone — using ONLY the global flag — to pick the special-zone colours (Mare Lamentorum / Ultima Thule branches at lines 155, 163, 170, 177). When the user disabled effects PER-DEVICE but left the global toggle on, this picked the "animation enabled" colour even though the device shouldn't be participating, which manifested as the highlight layer changing colour after the toggle.

Fix: thread the already-computed per-device flag from the caller into SetReactiveWeather as a `bool effectsActive` parameter. The special-zone branches now use it instead of the bare global flag, so the redraw and the colour selection resolve to the same state. Disabling effects via the per-device toggle now matches the global-toggle behaviour the user already expects.

BaseLayers/ReactiveWeather.cs already had the per-device flag threaded correctly (added in v4.1.13); only the highlight had the standalone re-read.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (v4.1.16)

The per-device EffectLayer gate I added at v4.1.13 to BuildTitleScreenAnimation + RunStartupEffects' initial-build sweeps was over-applied. Tagged effects are one-shot animations — they get painted ONCE at title-screen detection or Chromatics startup, then drift on the surface render until torn down. Filtering at the initial sweep meant any persisted EffectLayer state with Enabled=false (which could result from earlier testing of the per-device toggle) silenced the animation on that device permanently.

In the user's case the title-screen animation appeared to stop entirely because the persistence had at least one EffectLayer disabled, the gate fired for that device, and they perceived the missing animation as broken.

Removing the gate from both initial-build sweeps. Mid-animation per-device toggles still work — LayerItemViewModel.OnIsEnabledChanged calls RGBController.SyncTaggedEffectsForDevice, which detaches just the toggled device's group from the active animation without disturbing other devices. The per-frame effect-class processors (raid effects, duty bell, damage flash, cutscene, vegas, weather animations) keep their per-device gates because they re-evaluate every tick — those are correctly respected.

Net result:
  - Title screen / startup animation paint on every connected device by default.
  - Effects-tab global toggles (effect_titlescreen, effect_startupanimation) still disable the animation entirely if the user wants it off.
  - Disabling EffectLayer for a single device while a tagged animation is currently running detaches just that device's group via SyncTaggedEffectsForDevice.
  - Per-frame effect-class processors continue to honour the per-device gate every tick.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v4.1.14 refactor lifted the title-screen ledgroup construction out into a per-device helper (BuildTitleEffectForDeviceInternal). The user reports title-screen animation has stopped working entirely since the EffectLayer changes; v4.1.16's gate-removal didn't fix it.

I can't see a functional difference between the original inline foreach (v4.1.12 — known working) and the v4.1.14 refactor through code inspection — palette/baseColor/highlightColors are recomputed per-call in the helper but the values are the same; RegisterTaggedEffect with deviceGuid is functionally a superset of the 2-arg version. But the user's bisect points at this set of changes, so reverting BuildTitleScreenAnimation back to its v4.1.12 inline-foreach form, with the only delta being it now tracks the source deviceGuid in the new RegisterTaggedEffect overload so SyncTaggedEffectsForDevice can still detach a single device's group on a per-device toggle.

The BuildTitleEffectForDeviceInternal helper stays in place but is now ONLY called from RGBController.SyncTaggedEffectsForDevice's "device just re-enabled mid-animation, build it a fresh group" path. Initial title-screen build is back to the original inline form.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reports the title-screen animation plays for one frame and then transitions to the in-game effect-layer pipeline even though the user is still on the title screen — strongly suggesting one of the three Sharlayan signals driving the title-detection branch (Entity == null, chatLogCount <= 0, !IsLoggedIn) is flipping false between consecutive ticks.

Adds change-driven logging to the Console tab (LoggerTypes.FFXIV) at GameProcessLayers' title-detect branch: logs on first observation and on any subsequent change to entityIsNull / chatCount / isLoggedIn / branchDecision. Also logs the build/teardown actions when they actually fire. Throttling is implicit — only changes log, so a stable in-game session emits no spam.

Sample output the user should see when the issue reproduces:
  [Title detect] entityNull=true chatCount=0 isLoggedIn=false → branch=title (_onTitle=false, wasPreviewed=false)
  [Title detect] Building title-screen animation (_onTitle=false, wasPreviewed=false).
  [Title detect] entityNull=??? chatCount=??? isLoggedIn=??? → branch=in-game (_onTitle=true, wasPreviewed=false)
  [Title detect] Tearing down title animation, transitioning to in-game (...)

The "???" line is exactly what we need to see — whichever signal flipped is the culprit. From there: either Sharlayan resolver bug, stale-data race in handler.Reader, or chat log mid-init populating system messages early.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fectLayer gate (v4.1.19)

Diagnostics from v4.1.18 confirmed the cause: Sharlayan's chat-log reader picks up a system message ("Welcome to FFXIV" or similar) on the title screen, flipping ChatLogItems.Count from 0 to 1 within a frame of the title animation building. The original detection condition (entityNull && chatLogCount<=0 && !isLoggedIn) interpreted that flip as "user has logged in" and tore down the just-built animation.

Fix: drop the chatLogCount check entirely. Player Entity == null AND IsLoggedIn == false is unambiguous for title vs in-game on its own — Sharlayan keeps both flags consistent (Entity stays non-null during zone transitions because IsLoggedIn stays true; both flip false together only on logout/title).

With the underlying detection now reliable, restoring the per-device EffectLayer gates at the tagged-effect initial-build sweeps that I'd backed out in v4.1.16:
- BuildTitleScreenAnimation: skips devices whose EffectLayer toggle is off.
- RunStartupEffects: same.

Mid-animation per-device toggles continue to work via SyncTaggedEffectsForDevice from LayerItemViewModel. The diagnostic logging from v4.1.18 stays in place — change-driven, low spam, useful for any future detection regressions.

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ remove title-detect diagnostics (v4.1.20)

User confirmed via the v4.1.18 diagnostics that Sharlayan's chat-log reader populates ChatLogItems.Count = 1 on the title screen with a system message — exactly the regression that was making title-screen detection flip to in-game after one frame. Diagnostic code removed; the v4.1.19 condition (entityNull && !isLoggedIn) was the right fix.

User then noticed effects (title-screen starfield, reactive-weather animation overlays, etc.) didn't paint on PlayStation DualShock 4 lightbar (1 LED) or Hue bulbs (1 LED per bulb), and identified single-LED devices as the common thread. Cause: every Starfield-class decorator computes star count as `count / 4`, which rounds down to 0 on 1–3 LED devices. With 0 stars no LED ever enters fadingInLeds / fadingOutLeds, the LED never gets a colour, and the device shows nothing.

Fix: floor numberOfLeds to 1 in all four Starfield-class decorator constructors when the ledgroup has at least one LED — StarfieldDecorator, FastStarfieldDecorator, BPMStarfieldDecorator, BPMFastStarfieldDecorator. The constructor-level clamp covers all 12+ call sites across BuildTitleScreenAnimation, RunStartupEffects, ReactiveWeather (BaseLayer + Highlight), CutsceneAnimation, and the raid-effect zone-specific branches in RaidEffectProcessor without touching each one. With 1 star on a 1-LED device, that LED becomes the star and cycles through the highlight palette — the correct visual analogue.

Also added explicit Math.Max(1, count/4) at the BuildTitleScreenAnimation and BuildTitleEffectForDeviceInternal call sites for redundancy / clarity (defence in depth — call site signals intent, constructor guarantees correctness).

130/130 xUnit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@logicallysynced
Copy link
Copy Markdown
Owner Author

Closing in favour of #188 — branch renamed from chromatics-4.0.x to chromatics-4.x. Same commit history, same body, latest version is now v4.1.20.

@logicallysynced logicallysynced deleted the chromatics-4.0.x branch May 8, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant