Release 4.3.x#204
Merged
Merged
Conversation
…(v4.2.73 -> v4.3.1) Multi-agent audit of the full codebase (67 raw findings, adversarially verified down to 47; 3 build-script credential items handled separately by the owner). Fixes by area: Core: lock discipline for _runningEffects and _activeDevices, _layergroups swapped to ConcurrentDictionary (game loop vs RGB.NET timer thread race), dead CPU-throttle branch removed, Sentry heartbeat no longer blocks a thread-pool thread. Layers: DamageFlash min-opacity clamp inverted the whole (0,1) range, JobGaugeA/B/C interpolate mode rebuilt LED groups every tick, missing _disposed guard in TargetCastbar, per-tick brush and dictionary allocations hoisted across nine processors, CutsceneAnimation dead decorator allocation removed, GetLedArray O(n*m) scan now a HashSet. Decorators: ArenaLightShow blended a 0-1 base color into a 0-255 scale (base color invisible), Strobe/Pulse detach race guards, RectangularGradient bottom-edge interpolation typo. Devices: Yeelight dedup state no longer poisoned by failed sends (bulb recovers after Wi-Fi drops, errors surface once per outage), Music Mode accept task leak fixed, LIFX and QMK inter-chunk sleeps moved outside the device lock (UI no longer freezes on multizone re-enable), per-frame buffer allocations removed in DynamicLighting, Alienware, EVision, Redragon and QMK queues. Helpers: settings deserialization NRE in DictionaryConverter, Blacksmith job used Black Mage palette colors, palette/effects/settings saves now atomic (tmp + replace), GetMaxCpuUsage read a memory counter. UI: Hue adoption dialog mutated ObservableCollection off the UI thread, LocalizationService subscription leaks in layer items and adoption dialogs, console trim no longer fires 500 list shifts, registry key in SettingsViewModel now scoped and disposed. Harness: SavePreset now round-trips all 28 effect parameters. Build clean, 133/133 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…203) The retry v4 major only moves the action to the node24 runtime; inputs and retry behaviour are unchanged, and windows-latest runners ship node24. Applied here so the release branch carries it; dependabot closes #203 once this reaches master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion (v4.3.1 -> v4.3.2) Sharlayan 9.1.1 is the full-codebase-audit release (74 verified fixes plus an FFXIVClientStructs submodule refresh, FFXIVAPP/sharlayan PR #116). Referenced via NuGet in both Chromatics.csproj and Chromatics.Tests.csproj as before. Compile fixes for the one breaking change Chromatics touches: AstrologianResources.DrawnCards went from List<AstrologianCard> to AstrologianCard[], so the two .Count reads became .Length - JobGaugeB.cs (AST next-card gauge) and JobGaugeC.cs (AST draw-state gauge). The [0] indexer and null checks compile unchanged. Verified non-impacts per the migration notes: - ActionContainers / InventoryContainers ConcurrentBag -> List: Keybinds.cs uses null check + foreach only, compiles unchanged. The ConcurrentBag in LifxDiscovery.cs is Chromatics' own and unrelated. - DancerResources.Steps -> DanceStep[]: no Chromatics usage. - MonkResources.BeastChakra now get-only: no Chromatics usage (JobMNKBeastChakra is a palette entry, not the Sharlayan type). - MateriaType ushort-backed: no (byte) casts anywhere in Chromatics. User-visible wins inherited from the Sharlayan fixes, called out in the 4.3.2 CHANGELOG entry: - RPR / SGE levels and EXP now populate, so the Experience Tracker layer works for Reaper and Sage (previously read 0). - Hotbar action ids above 32767 read correctly, fixing Keybinds layer dispatch on high-id actions. - Party member Coordinate.Y no longer mirrors Z. Build clean, 133/133 xUnit tests pass.
… forward-compat guard, schemaVersion 6 (v4.3.2 -> v4.3.3) Two new dynamic layer types mirroring TargetHP / TargetCastbar but reading TargetInfo.FocusTarget instead of CurrentTarget: - DynamicLayerType.FocusTargetHP = 16, FocusTargetCastbar = 17. Enum values equal their _dynamicLayerOrder positions per the position == enum-value invariant; both order arrays (LayerItemViewModel + CopyLayersDialogViewModel) got the matching appends and LayerProcessorFactory.CreateDynamicProcessor got the two cases. The stale DynamicLayerProcessorFactory map in LayerProcessor.cs has no callers (it already lacks JobGaugeC) and was left untouched. - FocusTargetHPProcessor clones the TargetHP structure: per-layerID model, claimed / friendly / idle brush selection from InCombat + IsAggressive, empty bar painted when nothing is focus targeted, Interpolate bar fill + Fade blend with the requestUpdate rebuild guard. - FocusTargetCastbarProcessor clones TargetCastbar but fixes the no-target path while at it: instead of the early return that freezes the last cast frame, it tracks the focus-target id, casts at 0.0 with no focus target, and repaints the empty bar on target change. Same requestUpdate guard on both modes. - Six new palette entries under Target/Enemy: Focus Target Cast Bar Charge Build / Empty and Focus Target HP Friendly / Claimed / Empty / Idle. Palette files saved by older versions load fine - missing fields fall back to the field initialisers. - LayerDisplay names + descriptions localised: 4 new en.json keys, all six locale files regenerated via translate.py. Forward-compat guard (per request): layers.chromatics4 written by a newer Chromatics can carry dynamic positions this build's enum doesn't define. MappingLayers.SanitizeLayerTypeIndexes validates layerTypeindex against the matching enum for the layer's rootLayerType and resets unknown positions to 0 (None) with a console line naming the layer. Runs in LoadMappings (startup path) and ImportMappings (Mapping-tab import + migration path). Without it, an unknown position flows into GameController's (DynamicLayerType) cast and the processor factory throws on every frame. schemaVersion bumped 5 -> 6 in MappingFileV3, SaveLayerMappings, and ExportLayerMappings to mark files that can carry the new positions. Build clean, 133/133 xUnit tests pass.
…maps (v4.3.3 -> v4.3.4) Neither static factory map had a single caller - GameController dispatches base and dynamic layers through LayerProcessorFactory.GetProcessor. Both maps had also drifted out of date: BaseLayerProcessorFactory was missing AudioVisualizer, DynamicLayerProcessorFactory was missing JobGaugeC and the new focus-target layers. Worse than harmless, their static initialisers eagerly built every processor singleton the moment anything touched the class. EffectLayerProcessorFactory stays - GameController iterates its map for effect-layer dispatch (GameController.cs:744). A comment above it records why its two siblings are gone so they don't get reintroduced by pattern-matching. CHANGELOG header renamed 4.3.3 -> 4.3.4 to track the version (also fixes the "Castbasr" typo in the focus-target bullet); the existing "Various performance improvements and bug fixes" line covers this cleanup. Build clean, 133/133 xUnit tests pass.
…ds, Reactive, Wireframe, One-shot Pulse + BPM variants) and wire into harness (v4.3.4 -> v4.3.5) New decorators in Extensions/RGB.NET/Decorators, all grid-driven via DeviceGridHelper and following the established per-tick render model (detach group on attach, paint LED colors directly, base-color fallback for off-grid LEDs): - SnowstormDecorator: drifting flakes with per-flake speed jitter and cross-axis sway. Eight directions including all four diagonals. Params: direction, speed, flake count, flake colors, base. - BPMSnowstormDecorator: same field, flake travel time locked to beatsPerCycle beats using the shared speed = extent / (beats * beatInterval) model. - PinwheelEffect: angular fan blades rotating around the grid centre, twist * distance bends blades into spiral arms, row axis compressed 2x so blades stay even on wide keyboard grids. Params: speed (deg/s), fan count, twist, color per fan (cycled), base. - BPMPinwheelEffect: one blade-to-blade step per beatsPerCycle beats so a fresh blade lands on every beat. - BlueParticlesEffect: rising particles with horizontal wander and a triangular fade-in / fade-out age envelope. Params: count, rise speed, highlight colors, base. - BPMBlueParticlesEffect: burst of N particles on each beat, each living exactly one cycle so the field breathes with the music. - CloudsEffect: two-octave value-noise field scrolling along the column axis; lower half of the noise range remapped to clear sky so clouds read as shapes rather than full cover. Multiple highlight colors band by density. Params: drift speed, opacity, colors, base. - BPMCloudsEffect: drift synced to beats plus a subtle per-beat opacity swell. - ReactiveKeyboardEffect: expanding ring anchored at a configurable LedId (falls back to grid centre when the key is absent), energy falloff over distance, auto-respawn with color cycling. Params: start key, speed, ring width, colors, base. No BPM variant - the effect is origin-driven, not tempo-driven. - WireframeEffect: mirrored horizontal / vertical pillar pairs fanning out from centre to the edges, hue wheel assigns each pillar its rainbow color at spawn. Params: speed, spawn interval, beam width, base. Color slots unused by design. - BPMWireframeEffect: one pillar pair per beat, edge arrival locked to beatsPerCycle. - OneShotPulseEffect: single radial pulse from device centre over the full DeviceGridHelper grid - unlike PulseDecorator's hardcoded step lists, the ring crosses the numpad, nav cluster and media keys, and works on non-keyboards. Multi-color ring banding across the ring thickness. Exposes IsFinished for owner-managed teardown. Origin note: Blue Particles, Clouds and Reactive recreate the look of samples on Razer's Chroma Guide. The guide page is client-rendered so the sample gallery wasn't fetchable; the implementations are original interpretations of those effect archetypes built on the Chromatics grid model. The Chroma Animation API reference confirmed the SDK's model is pre-baked frame sequences, which doesn't map to Chromatics' procedural per-tick decorators - nothing was ported. Harness wiring (DecoratorHarnessUI): 12 new RawEffects entries, ApplyDefaults presets, live BuildEffect cases, code-snippet generator cases, and UpdateParamVisibility coverage. Five new parameters with AXAML rows: Fans, Spiral Twist, Opacity, Starting Key (ComboBox over common keys, mapped to LedId via Keyboard_ + name), Snow Direction (8-way). SavedParams record extended so saved presets capture the new fields. Build clean, 133/133 xUnit tests pass.
…us effects (v4.3.5 -> v4.3.6) DutyFinderBell, DamageFlash and CutsceneAnimation shared one ListLedGroup per device (all keyed on the same effect-layer id), so concurrent effects overwrote each other's brush - a damage flash during a duty pop swallowed the bell for the rest of that pop. Each processor now paints its own group, registered additively via RGBController.RegisterLiveLayerGroup so the generic requestUpdate cleanup still detaches all of them. Z-order is pinned in the new EffectZIndex constants, top to bottom: DamageFlash (1500) > DutyFinderBell (1400) > Cutscene (1300) > TitleScreen (1200) > VegasMode (base slot, by design) > Startup (1000). Idle groups hold a transparent brush, which Color+ treats as a no-op, so stacking is free outside an active effect. DamageFlash's old 700 floor and DutyFinderBell's ZIndex=2000 disable quirk are gone; raid overlays (500/600/700) now sit below the whole effect stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ireframe pillar modes (v4.3.6) Pinwheel (both variants): rotation term flipped from added to subtracted in the blade phase. With twist * dist on the spiral, a fixed colour band now satisfies twist * dist = const + rotation, so the bands radiate outward from the centre as the wheel turns instead of sucking inward. The visual the previous sign produced was a static spiral spinning in place. ReactiveKeyboardEffect: oneShot constructor flag plus an IsFinished property matching the OneShotPulseEffect contract. In one-shot mode the wave expands once, the board settles on the base colour, and the owner polls IsFinished to remove the decorator. Repeating mode is unchanged and stays the default. Wave state resets on attach so a re-attached one-shot fires again. Wireframe (both variants): new PillarMode option - Random (previous behaviour, each spawn picks an axis at random), Vertical only, Horizontal only, and Alternating (strict axis alternation per spawn). Mode logic is shared through WireframeEffect. NextPillarVertical so the BPM variant can't drift. Harness: One Shot toggle on ReactiveKeyboardEffect, Pillar Mode ComboBox on both wireframe effects, defaults, code-snippet output (including the IsFinished polling note when one-shot is on), and SavedParams coverage for both new fields. No version bump: the concurrent effect-z-order workstream already advanced the csproj to 4.3.6 and these changes ship under it. Build clean, 133/133 xUnit tests pass.
…s palette, schemaVersion 7 (v4.3.6 -> v4.3.7) Two new Effects-tab toggles, both rendered through the new RippleBrushDecorator rather than the harness grid decorators. Those write LED colors directly from a detached group, which loses to every attached base / dynamic brush in the live render stack; the brush decorator paints the same expanding ring through IBrushDecorator.ManipulateColor on an attached group's transparent brush, so it composites at its EffectZIndex slot and bleeds through outside the ring (alpha 0 is a Color+ no-op) - the "no base" requirement falls out of the architecture. Casting Success (default ON, EffectZIndex 1350 - just under the Duty Finder Bell): - Fires when a cast completes: IsCasting1 drops with the last sampled CastingPercentage at >= 0.85. The threshold absorbs the 200ms game loop granularity; interrupted casts sampled below it stay silent. - Ring launches from Keyboard_J (centre of the board; grid-centre fallback for devices without a J), speed 16, ring width 2.0. - Colour from the new "Casting Success" palette entry under Notifications, default white. Status Inflicted (default OFF, EffectZIndex 1250 - just under the cutscene tier): - Fires when a new detrimental status lands on the player. The StatusEffects palette category doubles as the detrimental whitelist: a status only pulses when its English name matches an entry, which filters beneficial / neutral statuses without a Lumina sheet lookup, and the matched entry supplies the pulse colour from the user's active palette. - Latest received wins within a tick; 5 second cooldown between pulses; first populated tick seeds a baseline so statuses already on the player at attach (food, DoTs mid-fight) don't fire. - Centre-out pulse, speed 12, ring width 2.5. Status Effects palette: category unhidden in the Palette tab and the dictionary refreshed with 23 recurring detrimental statuses from modern content (Doom, Frostbite, Windburn, Dehydration, Disease, Concussion, Sustained Damage, Magic / Physical Vulnerability Up, Healing Magic Down, Confused, Hysteria, Seduced, Fetters, Electrocution, Throttle, Temporary Misdirection, Forced March, Down for the Count, Brink of Death, Weakness, Nausea) with default colours. Names match StatusNameEnglish so the matcher works across client languages. Plumbing: EffectLayerType.CastingSuccess = 5 / StatusInflicted = 6 registered in both factories; layers.chromatics4 schemaVersion 6 -> 7 (new effect-layer value space; SanitizeLayerTypeIndexes already guards older builds); effect_castingsuccess / effect_statusinflicted persisted in effects.chromatics4; cast.png + debuff.png wired as AvaloniaResource (debuff.png was tracked but never registered, the avares URI would have thrown at tile construction); Effects-tab tiles localised through LocalizationService with 4 new en.json keys and all six locale files regenerated. Build clean, 133/133 xUnit tests pass.
…ferences (v4.3.7 -> v4.3.8) Checked the dictionary against the consolegameswiki detrimental status list (fandom and gamerescape both 403 on automated fetch). Match rule: the downward-arrow detrimental set, names kept identical to in-game StatusNameEnglish because the Status Inflicted effect matches on the display name. Two legacy display names were wrong and would never have matched at runtime: - "Bleed" -> "Bleeding" (field name Bleed kept for palette-file compatibility) - "Infirmary" -> "Infirmity" (typo in the original Chromatics palette; field name kept) Eighteen additions with default colours: - Accuracy Down, Blind, Brush with Death, Charm, Seduce, Pacification, Reduced Immunity, Sludge - All six elemental Resistance Down statuses (Fire / Ice / Wind / Earth / Lightning / Water) - common raid debuffs on players - The three physical Resistance Down statuses (Blunt / Piercing / Slashing) Existing entries not on the wiki list (Dehydration, Concussion, Sustained Damage, Healing Magic Down, Fetters, Throttle, Temporary Misdirection, Forced March, Nausea, Daze, Old, Pollen, Pox, Leaden, Misery, Incapacitation) stay - they're fight-specific statuses the general list doesn't carry. Saved palettes from older versions keep their existing entries and colours; the new fields fall back to the defaults above. Build clean, 133/133 xUnit tests pass.
…alette schemaVersion 3 (v4.3.8 -> v4.3.9) The Status Effects category now carries every detrimental status the game data defines: 1155 generated entries on top of the 58 curated ones, sourced from the Status sheet via XIVAPI (StatusCategory 2 - the downward-arrow set), deduplicated by name, with encrypted _rsv_ placeholder rows filtered out. Covers the fight-specific battle debuffs the curated list missed. Default colours are assigned per-name from a fixed 32-colour debuff set via a stable hash, so a given status keeps the same default colour across builds. Every entry's display name doubles as the StatusNameEnglish match key for the Status Inflicted effect, so the whole catalogue is now triggerable and per-status colour-editable from the Palette tab. Palette versioning: palette.chromatics4's existing version field is the schema version and is bumped 2 -> 3. The mechanics that make new entries appear for existing users: - Deserialisation already merges the file onto a freshly-constructed model, so fields missing from an older file arrive from their C# initialisers. New entries were therefore visible in the UI but never persisted - nothing rewrote the file. - MigratePaletteIfNeeded re-saves on any version mismatch, so the 2 -> 3 bump makes the first launch persist the full catalogue into the user's file with their existing colours untouched. - Forward compatibility: a v3 file opened by an older build loads fine (unknown JSON members are ignored); the old build drops the extra entries on its next save and a newer build restores them from the initialisers again. Three new tests pin the contract: missing-member fallback to initialisers, unknown-member tolerance, and status-name uniqueness across the catalogue (the colour lookup would go ambiguous on a duplicate). Build clean, 136/136 xUnit tests pass.
… StatusItem.IsDetrimental (v4.3.9 -> v4.3.10) Sharlayan 9.1.2 adds IsBeneficial / IsDetrimental / CanDispel to StatusItem (resolved from the Status sheet's StatusCategory, where 2 is the downward-arrow detrimental set). StatusInflictedProcessor now uses IsDetrimental as the trigger gate instead of the palette-name whitelist. The palette map keeps one job: supplying the pulse colour. A detrimental status missing a palette entry (a future patch's status on a build whose catalogue predates it) falls back to a fixed crimson instead of silently skipping, so the pulse fires for every debuff the game flags as detrimental regardless of catalogue freshness. Latest-wins, the 5 second cooldown, and the first-tick baseline seeding are unchanged. Build clean, 136/136 xUnit tests pass.
…3.11) Ring travel speeds reduced by a quarter so the pulses read as a sweep rather than a flash: Casting Success 16 -> 12 cells per second, Status Inflicted 12 -> 9. Ring widths unchanged. Build clean, 136/136 xUnit tests pass.
…e pixel (v4.3.11 -> v4.3.12) The hash-assigned defaults on the session-added detrimental entries are replaced with colours read from the actual status icon art: each icon fetched from the XIVAPI asset endpoint (Status sheet Icon path, first row per name to match the catalogue's dedup rule) and sampled at the centre pixel. Where the centre pixel is transparent the sampler averages the opaque pixels in the surrounding 5x5 block. Coverage: 1187 of the 1194 in-scope entries recoloured - 1140 from the exact centre pixel, 47 from the 5x5 fallback - producing 655 distinct colours. Seven entries keep their previous defaults: two icons are fully transparent at sampleable density, four catalogue rows carry no icon path, and one icon asset 404s (StatusBattleEfficiencyDown). Legacy entries that predate this session (Poison, Stun, Sleep, Bind, ...) are untouched per scope. Spot checks against recognisable art: Doom #991122 (dark red skull), Frostbite #AABBBB (icy grey-blue), Sludge #AA7722 (amber). Entries now initialise via Color.FromArgb(r, g, b) instead of named colours; ColorMapping serialisation is unchanged, so saved palettes keep user edits and only fresh installs / migrated files pick up the sampled defaults. Build clean, 136/136 xUnit tests pass.
… lights to their pre-Chromatics state (v4.3.12 -> v4.3.13) Both providers had two single-attempt network operations whose silent failure produced the "lights keep the effect colour about one close in four" report: Capture (at adoption). LIFX queried original state with one UDP round-trip on a 500ms timeout; one dropped datagram left _original null and the shutdown restore no-opped for that bulb. Hue did one CLIP v2 GET; a bridge busy with the provider's adoption burst left _capturedLight null with the same result. Both now retry three times - LIFX with escalating 500/750/1000ms timeouts, Hue with 300ms-stepped backoff. Restore (at close / disable). LIFX sent each restore packet exactly once over fire-and-forget UDP; one dropped SetColor / SetExtendedColorZones / SetLightPower datagram froze the bulb at the last effect colour. The whole colour + power sequence now repeats three times with 120ms gaps - the sets are idempotent, and three sends turn a few percent per-packet loss into effectively zero. Hue sent each PUT once; the bridge sheds CLIP v2 calls under load, so a dropped PUT lost the restore. Each PUT now retries three times with backoff, the colour and power PUTs get a 100ms gap so they stop landing as a burst, and a verify GET afterwards re-asserts the power state when the bridge accepted the PUT without applying it. Teardown budgets resized to fit: LIFX per-bulb timeout 1.5s -> 3s, total min(20, 2 + 2N); Hue per-bulb 2.5s -> 5s, total min(30, 2 + 4N). Both still run inside the blocking Dispose wait on the window-close path, ahead of the process kill. Build clean, 136/136 xUnit tests pass.
Same API surface as the prerelease the codebase already targets (StatusItem.IsBeneficial / IsDetrimental / CanDispel, DrawnCards array). Both Chromatics.csproj and Chromatics.Tests.csproj updated. NuGet's registration index lagged the freshly-published package; restore needed a local http-cache clear before it resolved. Build clean, 136/136 xUnit tests pass.
… of crashing startup (v4.3.14 -> v4.3.15) Fixes CHROMATICS-17 and CHROMATICS-18: on machines where Windows App Control / Smart App Control blocks one of the unsigned vendor DLLs (0x800711C7 - Chromatics signs only its own binaries by policy), the FileLoadException fired when RGBController.Setup was JIT-compiled. Type references in Setup's body resolve their assemblies at the call site, before Setup's own try/catch exists, so the fault escaped the async void MainWindow.OnOpened handler onto the dispatcher and crashed the app on open. Sentry captured RGB.NET.Presets.dll (CHROMATICS-17) and RGB.NET.Devices.CoolerMaster.dll (CHROMATICS-18, Japanese-locale App Control message) as the blocked files. Fix: new TryLoadProviderIsolated(label, Action) helper. Every Setup block that references an externally-shipped assembly now runs inside a lambda - lambda bodies compile to their own methods, so the blocked assembly resolves when the lambda executes, inside the helper's catch. Wrapped: Logitech, Corsair, CoolerMaster, Novation, Razer, ASUS, MSI, SteelSeries, Wooting (RGB.NET.Devices.*), OpenRGB (+ OpenRGB.NET), Hue (HueApi), PlayStation (RGB.NET.Devices.PlayStation), and the TimerUpdateTrigger creation (RGB.NET.Presets). Providers implemented inside Chromatics.dll (LIFX, Yeelight, QMK, Alienware, Dynamic Lighting, Redragon, EVision) can't hit this fault and keep their existing handling. Behaviour on a blocked DLL now: the affected provider logs a console error naming the blocked file with Windows Security guidance, every other provider keeps loading, and a blocked RGB.NET.Presets aborts lighting for the session with a clear message instead of a crash. MainWindow.OnOpened gains a backstop catch so any residual load fault (for example a blocked RGB.NET.Core) logs and disables lighting rather than crashing the dispatcher. Build clean, 136/136 xUnit tests pass.
New RGB.NET provider for Nanoleaf OpenAPI hardware: the panel family (Shapes, Canvas, Elements, Lines, Aurora) plus Skylight/4D by conformance. Essentials (Matter/Thread) are out of scope. Architecture (Chromatics/Extensions/RGB.NET/Devices/Nanoleaf/, in-assembly so it's immune to the App Control DLL-block class): - Protocol/: hand-rolled REST client (control plane, 3-retry backoff), extControl v2 UDP frame encoder (data plane, fire-and-forget), and an mDNS/DNS-SD discovery query with a manual-IP probe fallback. No new dependencies - HttpClient + Newtonsoft + raw UDP. - NanoleafUpdateQueue: enters streaming via REST, streams per-panel frames at nanoleafUpdateRateHz (default 20), dirty-checks with a 1s keep-alive, runs a 30s streaming watchdog, and gates on _shuttingDown / _perDeviceDisable like the LIFX/Hue queues. - Capture/restore: snapshots power + brightness + active scene name before streaming (scenes select by name); restore re-selects the scene, re-asserts power with retry, and verify-repairs the power state (the Hue shape). Persisted-disabled controllers are captured but never woken (the LIFX turnOnIfOff subtlety). - NanoleafDevice: one device per controller, one LED per panel at the layout endpoint's real x/y (y flipped to screen space, normalised), keyed by stable panelId onto Custom1..N so assignments survive restarts. LedMatrix for walls so grid effects engage natively. Adoption (Hue pattern): settings-only, excluded from the first-run wizard. NanoleafAdoptionDialog runs mDNS discovery, walks per- controller pairing (hold the button -> poll the token endpoint for ~30s), supports manual-IP add, and lists paired controllers with a remove action. Tokens persist in deviceNanoleafAdoptedDevices. Wiring: RGBController.Setup block after LIFX (plain try/catch); Settings tile "Nanoleaf (Beta)"; hidden nanoleafUpdateRateHz (clamped 1-60); 13 en.json keys + all six locales via translate.py. Tests (simulator-first, no hardware - ADR-0004): NanoleafSimulator fixture speaks REST + UDP on localhost with fault injection. 12 tests cover frame encoding, state/layout parsing, mDNS query bytes, pairing window, streaming enter, scene restore, and the retry-through-failures / give-up-after-three paths. Build clean, 148/148 xUnit tests pass (12 new).
logicallysynced
force-pushed
the
chromatics-4.x
branch
from
July 5, 2026 08:08
5bc58e8 to
e25ff6d
Compare
Root cause, reproduced by test on this machine: Windows converts an ICMP "port unreachable" from any earlier send into a SocketException on the socket's NEXT receive and keeps rethrowing it on every receive after that. The discovery loop only caught OperationCanceledException, so one ICMP reset became a repeated throw out of DiscoverAsync. Discovery also could never hear replies: mDNS answers multicast to port 5353, which the Windows mDNS service owns. A live probe of the old code confirmed zero packets received. Fixes in NanoleafDiscovery: - SIO_UDP_CONNRESET ioctl on the discovery socket turns off the ICMP reset reporting (extracted to CreateDiscoverySocket so tests prove the production config survives a reset and still receives). - Receive loop catches SocketException per iteration with a bail-out counter so a dead socket can't spin the loop. - The query now sets the QU (unicast-response) bit so controllers reply straight to our ephemeral port, and responses are checked for the _nanoleafapi label before being treated as controllers. The multicast group join is gone - group membership never mattered for a socket that isn't bound to 5353. - Query re-sends on quiet seconds inside the sweep window. Adoption dialog hardening (from review): async void entry points log and hold faults instead of crashing the app or swallowing silently; StartDiscoveryAsync / PairAsync / AddByIpAsync reset IsBusy in finally and recover the row state, so a fault can't leave the dialog spinning forever. New "Pairing failed. Try again." locale key in all seven languages. Tests: 3 new (ICMP reset mechanism on a default socket, production socket survives resets and still receives, service-label filter). 151/151 pass.
The previous fix contained the ICMP-reset SocketExceptions but left the loop exception-DRIVEN: each quiet second cancelled the pending ReceiveAsync, throwing OperationCanceledException as control flow. A debugger with break-on-thrown enabled (sticky after investigating the original crash) halts on every one, which presents as the app crashing at the receive line seconds after discovery starts. A first-chance probe measured 6 cancellation throws in one quiet 2.5-second sweep. The loop now polls udp.Available and naps 50ms between checks, so receives only run when a datagram is already queued and nothing throws on the healthy path. Query resends ride a stopwatch schedule instead of cancellation timeouts, and user cancellation lands through the loop condition within 50ms rather than via a thrown exception. Also switched the SIO_UDP_CONNRESET input to the canonical 4-byte BOOL form. New regression test records first-chance OperationCanceledExceptions originating in the production discovery class during a live sweep and asserts zero. Verified non-vacuous: the old slice-based loop fails it. 152/152 tests pass.
Manual-IP probe no longer throws for its expected outcome: the HttpClient-with-timeout probe reported "nothing there" by throwing TaskCanceledException, halting debuggers on every wrong or slow IP. It is now a SocketAsyncEventArgs TCP connect check, which reports failure through SocketError without an exception. New regression test pins the no-throw contract for refused, blackholed, and invalid addresses. Review findings fixed across the implementation: - RGBController's Mapping-tab dispatch now includes NanoleafDevice: disable gates the queue and restores the controller's scene; re-enable clears the gate, resets the dirty cache, and re-runs the extControl handshake (new EnsureStreamingAsync) - without it a disabled panel stayed stuck on its last colour and a re-enabled one stayed dark until restart. - A persisted-disabled controller that was powered on no longer gets pushed into streaming at startup; capture-only, hands off. - The watchdog's streaming re-entry now assigns the stream endpoint under the queue lock, closing a torn-read race with the 20Hz send path, and overlapping watchdog checks are prevented. - Update() hot path stops allocating per tick: reusable colour and frame scratch buffers, dirty check before encode, ms-based timing. - GetOnAsync reads the narrow /state/on endpoint instead of parsing the full panel layout for one bool. - Discovery's socket-error counter resets on a healthy receive so a transient blip can't abort the rest of the sweep. - DeviceName can no longer be null (GenerateDeviceGuid would throw on a hand-edited settings entry). - Missing "Pair" / "Remove" locale keys added in all seven languages; dead StreamEndpoint field and never-populated discovery fields removed; em dash swept from a comment. 153/153 tests pass.
An offline controller is an expected state - a dead pairing row polls POST /new once a second for 30 seconds, startup capture retries an unplugged controller, the watchdog checks mid-reboot - and HttpClient reports every one of those by throwing HttpRequestException. Handled, but it spams the debugger output and halts sessions with break-on-thrown enabled, one throw per poll. Every REST call now runs the no-throw TCP reachability check first (extracted from the manual-IP probe into NanoleafTcpCheck, shared by both), so HttpClient only runs when something is listening on the OpenAPI port. The retry helpers also stop manufacturing and throwing an HttpRequestException after exhausted attempts - callers already treat null/false as failure - and the queue logs when the streaming handshake fails instead of returning silently. New regression test: every REST entry point against an unreachable host returns null/false with zero first-chance exceptions from the client. 154/154 tests pass.
….20 -> v4.3.21) Panel identity: panels were mapped to LedIds by ordinal position in a live sort, so physically adding or removing a panel shifted every later panel's layer assignments onto different tiles. The mapping is now a persisted slot table (NanoleafAdoptedDevice.PanelOrder, seeded at pairing): a stored panelId keeps its LedId slot even while absent (tombstone), so removals never shift the panels after them and a re-attached panel lands back on its old slot with its assignments intact. New panels append at the end, the change is logged with a pointer to the Mappings tab, and the resolved table persists back to settings. Settings saved by older builds hydrate with an empty table and initialise to today's ascending order, so nothing shifts on upgrade. NanoleafPanelSlots.Resolve is a pure helper with four unit tests (fresh adoption, removal keeps slots, addition appends, returning panel reclaims its slot). Shutdown: Unload() disposed providers sequentially, and the stateful smart-light providers each block in Dispose while restoring (budgets up to ~30s), making close time additive across brands. LIFX, Hue, and Nanoleaf disposes are now deferred past their surface detach and run in parallel under one 30s global ceiling; they share no sockets or endpoints, and per-device pacing lives inside each provider's own Dispose. Native-SDK providers keep the original sequential dispose on the calling thread. 158/158 tests pass.
…IFX budget (v4.3.21 -> v4.3.22) Four confirmed findings from the full PR #204 review: - The App Control bail-out in RGBController.Setup returned before _loaded was set, so Unload() no-oped on exit and every provider loaded earlier in the same call leaked: no smart-light restore, no native SDK release. The bail-out now unloads the registered providers before returning. - _runningEffectsLock only guarded RGBController's own mutations while ReactiveWeather, RaidEffectProcessor, and CutsceneAnimation mutated the live list unlocked on the game-loop thread - enabling a provider from Settings mid-session raced a locked Clear() against unlocked Add/Remove, a real List<T> corruption hazard. The live list is no longer handed out: processors call the new locked AddRunningEffect / RemoveRunningEffect, and the list parameter threading through the effect helpers is gone. - The Bleed -> "Bleeding" and Infirmary -> "Infirmity" display renames never reached upgrading users: Name is persisted, so old palette files restored the stale strings forever - visible now that the Status Effects category is shown in the Palette tab. NormalizePaletteDisplayNames covers both (the NIN Huton precedent), with a test pinning the correction and its idempotence. - The LIFX shutdown restore budget (min(20, 2+2N)s) was smaller than the loop's own worst case (~3.1s per unreachable bulb), so bulbs late in the list never started restoring before the process died. Budget is now min(30, 2+3.2N)s, matching the per-bulb worst case and the 30s global shutdown ceiling. 159/159 tests pass.
… crash (v4.3.22 -> v4.3.23) Fixes CHROMATICS-19 Fixes CHROMATICS-1C CHROMATICS-19 (+ CHROMATICS-1B feedback): AppSettings.Startup applied the global brightness through GlobalBrightnessCorrection, which implements an RGB.NET interface - so the JIT loads RGB.NET.Core.dll just to compile Startup. On machines where App Control blocks that DLL, settings load itself faulted before any of RGBController.Setup's guards could help. The reference now lives in its own NoInlining method with a load-fault catch, so a blocked core library costs the brightness setting a warning instead of the startup path. CHROMATICS-1C: the Hue/LIFX/Yeelight enable toggles built their already-adopted dictionaries with ToDictionary over the persisted device lists - a bulb saved twice (the crashing user had a duplicated Yeelight id) threw ArgumentException inside the toggle's async path and took the app down as an unobserved task exception. All five dictionary builds (three toggles, two dialog re-run paths) now GroupBy their key and take the newest entry, and the next save writes the deduplicated list back. 159/159 tests pass.
…maining dedupe sites (v4.3.23 -> v4.3.24) Fixes CHROMATICS-19 Six findings from the review of the previous two fix commits: - The CHROMATICS-19 fix in AppSettings.Startup did not work: the RGB.NET reference sat in the isolated method's own body, so the method's JIT compilation faulted before its try/catch existed and the exception surfaced at Startup's unguarded call site (the CHROMATICS-17 lesson). The guard logic now lives in a shared Helpers.AssemblyLoadGuard.TryRun(label, action) with the risky references inside the lambda; TryLoadProviderIsolated delegates to it, so the catch filters can no longer drift. - CHROMATICS-19 had a second unguarded path: RGBController's static field initialiser constructs the RGB.NET surface, and Program.Main reaches it through the expansion migration before Setup's guards exist. The migration body now runs under AssemblyLoadGuard, and the expansion marker only advances on success so a blocked machine retries after the user allows the library. - Provider teardown now runs on every Setup exit: the trigger bail-out, the outer catch (a throw from AlignDevices previously leaked every loaded provider the same way), and Unload all share TeardownLoadedProviders, which keeps the parallel stateful-restore under the 30s ceiling instead of the bail-out's sequential ~90s worst case. - The first-run wizard's Hue step still crashed on a duplicated persisted light; and adopted-device lists now dedupe once at settings load (newest entry wins), so duplicates can no longer hydrate two devices for one bulb through any provider path. - The LIFX restore budget derives from the loop's own timeout and pacing constants, with the comment now honest about the 30s cap truncating 9+ all-unreachable fleets. - Palette rename test extended to pin all three renames correcting in one pass. 160/160 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings master from 4.1.38 to 4.3.15 - covering the 4.1.44 to 4.2.73 releases that already shipped through the Velopack update channel, the 4.3.1 audit pass, and the 4.3.x feature work this PR prepares for release.
What each version added
The 4.3.1 audit, by area
Thread safety
_runningEffectsand_activeDevicesin RGBController were plain collections written from device-SDK callback threads while the game loop and UI thread read them. Both now sit behind dedicated locks; teardown paths snapshot before detaching._layergroupswas a plain Dictionary mutated by the game loop while the RGB.NET timer thread read it throughsurface.Updating. It is now a ConcurrentDictionary, with all 24 processor write sites and 4 removal sites converted.Thread.Sleep(1500)every minute. It is now async end to end.Logic bugs
ConfigureAwait(false). Continuations now resume on the UI thread._disposedguard.Performance
GetLedArrayran an O(n*m) LINQ scan per frame; it now uses a HashSet.Resource leaks
Dead code
Two findings needed no change on inspection (one was already in the recommended form, one targets an app-lifetime object where unsubscription buys nothing), and the harness gained a fix of its own: effect presets now round-trip all 28 parameters instead of a third of them.
Every fix was re-reviewed against the diff before commit. That second pass caught and corrected two regressions in the first round of fixes (a snapshot return that broke effect registration, and a stale-buffer repaint in Dynamic Lighting), one log-spam path, and one compile error.
CI
nick-fields/retrybumped v3 to v4 andactions/checkoutv6 to v7 in ci.yml. Dependabot PRs Bump nick-fields/retry from 3 to 4 #203 and Bump actions/checkout from 6 to 7 #205 closed as superseded.Verification
🤖 Generated with Claude Code