Skip to content

Release 4.3.x#204

Merged
logicallysynced merged 30 commits into
masterfrom
chromatics-4.x
Jul 7, 2026
Merged

Release 4.3.x#204
logicallysynced merged 30 commits into
masterfrom
chromatics-4.x

Conversation

@logicallysynced

@logicallysynced logicallysynced commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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

  • 4.1.44: QMK Raw HID keyboard support (Beta) with a pre-built layout database covering 2650 boards.
  • 4.2.50: Windows Dynamic Lighting, Yeelight and Alienware LightFX support (all Beta), layer copying between devices, Hue/LIFX/Yeelight in the first-run wizard, multi-zone keyboard layouts, code-signed installers, and a batch of fixes (Vegas Mode in the Gold Saucer, title animation during zone loads, clipboard loss on close).
  • 4.2.65: EVision keyboard support (13 boards on the Sonix VS11K28A firmware), Redragon mouse support (13 models), updated Corsair/CoolerMaster/MSI/Wooting SDKs, and a fix for Corsair and OpenRGB providers failing to start.
  • 4.2.73: FFXIV 7.51 support, layer remapping on keyboard-type change, AZERTY fixes, a crash fix when changing layer modes, and two Enmity Tracker fixes (dark while engaged, dark after disable/re-enable).
  • 4.3.1: a codebase audit. 10 subsystem auditors produced 67 findings; adversarial verification confirmed 47, and 44 were fixed (the other 3 are credential-handling items in private build scripts, handled outside the repo). Details below.
  • 4.3.2: Sharlayan 9.1.1 with the AstrologianResources.DrawnCards array migration. Inherited fixes: Experience Tracker now works for Reaper and Sage, hotbar action ids above 32767 read correctly, party-member Y coordinates no longer mirror Z.
  • 4.3.3: Focus Target HP and Focus Target Castbar dynamic layers (positions 16/17), six focus-target palette entries, layers.chromatics4 schemaVersion 6, and the SanitizeLayerTypeIndexes guard so files from newer versions load on older builds with unknown layer types reset to None.
  • 4.3.4: removed the dead BaseLayerProcessorFactory and DynamicLayerProcessorFactory maps (no callers, drifted entries, eager singleton construction).
  • 4.3.5: twelve new lighting decorators (Snowstorm, Pinwheel, Blue Particles, Clouds, Reactive ripple, rainbow Wireframe, full-board One-shot Pulse, plus BPM variants) wired into the decorator harness.
  • 4.3.6: each whole-device effect now paints its own ledgroup with a fixed z-order (DamageFlash > DutyFinderBell > Cutscene > TitleScreen) so simultaneous effects composite instead of overwriting each other's brush. Decorator iteration: pinwheel spirals radiate outward, reactive gains a one-shot mode, wireframe gains pillar direction modes.
  • 4.3.7: Casting Success and Status Inflicted effects on the Effects tab, both rendered through the new RippleBrushDecorator so they composite in the effect z-order and bleed through outside the ring. Status Effects palette category unhidden; layers.chromatics4 schemaVersion 7.
  • 4.3.8 / 4.3.9: the Status Effects palette grew from ~24 entries to the full detrimental catalogue from the game data (1,200+ entries, deduplicated by name), with two legacy name fixes (Bleed -> Bleeding, Infirmary -> Infirmity) that had never matched at runtime. palette.chromatics4 gained schema versioning (v3) so entries added in new versions persist into existing users' files on first launch, with forward and backward compatibility pinned by tests.
  • 4.3.10: Sharlayan 9.1.2 prerelease; Status Inflicted now gates on StatusItem.IsDetrimental (Status sheet category) instead of a name whitelist, with the palette supplying colours and a crimson fallback.
  • 4.3.11: slowed the Casting Success and Status Inflicted ripples by a quarter.
  • 4.3.12: every session-added status palette entry's default colour is now sampled from the actual status icon's centre pixel (1,187 of 1,194 in scope; 655 distinct colours).
  • 4.3.13: LIFX and Hue state capture and restore now retry instead of giving up after one network attempt, fixing lights that kept the last effect colour after close about one time in four.
  • 4.3.14: Sharlayan 9.1.2 stable.
  • 4.3.15: fixed a startup crash on PCs where Windows App Control / Smart App Control blocks the unsigned vendor DLLs (Sentry CHROMATICS-17/-18). Provider assembly loads are isolated per provider, so a blocked DLL logs Windows Security guidance and disables only that provider.

The 4.3.1 audit, by area

Thread safety

  • _runningEffects and _activeDevices in 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.
  • _layergroups was a plain Dictionary mutated by the game loop while the RGB.NET timer thread read it through surface.Updating. It is now a ConcurrentDictionary, with all 24 processor write sites and 4 removal sites converted.
  • Strobe and Pulse decorators could crash the render loop when detached mid-update. Both now carry the null guards and try/catch the Starfield family already had.
  • The Sentry heartbeat blocked a thread-pool thread with Thread.Sleep(1500) every minute. It is now async end to end.

Logic bugs

  • DamageFlash clamped every minimum-opacity value below 1.0 to zero, which disabled the scale-damage-flash setting for its entire useful range. The clamp now floors at 0.
  • ArenaLightShow blended a 0-1 base color into a 0-255 channel scale, so the configured base color was invisible and the effect faded to black. Base channels are now scaled to match.
  • Blacksmith characters got Black Mage palette colors from a copy-paste error in GameHelper.
  • A missing assignment in DictionaryConverter threw NullReferenceException during settings deserialization on the fallback path.
  • Yeelight marked colors as delivered even when the TCP send failed, so one Wi-Fi blip stopped a bulb painting until the provider was toggled. Send state now updates only on success, failures retry next frame, and errors surface once per outage instead of once per retry.
  • The Hue adoption dialog mutated an ObservableCollection from a thread-pool thread after ConfigureAwait(false). Continuations now resume on the UI thread.
  • TargetCastbar was the only processor missing its _disposed guard.
  • Palette, effect and settings saves wrote directly to the target file, so a crash mid-write corrupted them. All three now use the same atomic tmp-and-replace pattern layer mappings already used.

Performance

  • JobGaugeA/B/C rebuilt every LED group on every game tick in Interpolate mode. They now rebuild only when the gauge value changes, matching the guard HPTracker and the other trackers already had.
  • Per-tick allocations removed across nine layer processors: cached brushes in Highlight, both BattleStances, JobClassesHighlight, JobClasses and TargetCastbar; a persistent dictionary in Keybinds; DamageFlash now allocates its decorator only when a flash actually fires; CutsceneAnimation allocated a StarfieldDecorator every tick and never used it.
  • Per-frame buffer allocations removed in the DynamicLighting, Alienware, EVision, Redragon and QMK update queues, with wire output verified unchanged.
  • LIFX and QMK held their device lock through inter-chunk pacing sleeps (up to 90ms for a 70-zone LIFX strip), which froze the UI thread when re-enabling a device from the Mappings tab. Packets are now built under the lock and sent with identical pacing outside it.
  • GetLedArray ran an O(n*m) LINQ scan per frame; it now uses a HashSet.

Resource leaks

  • LayerItemViewModel subscribed to the static localization event and never unsubscribed, leaking a full set of layer items on every refresh of the Mappings tab. The three device-adoption dialog view models had the same leak. All four now unsubscribe on disposal.
  • The Yeelight Music Mode listener leaked its pending accept task and any late-arriving socket.
  • SettingsViewModel held a registry key open for the life of the app; it is now scoped to each call.

Dead code

  • The GameLoop CPU-throttle branch could never execute (its flag was set false on the line above the test) and is removed, along with a helper that read a memory counter and returned it as a CPU percentage.

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

Verification

  • Release build of all three projects: 0 warnings, 0 errors.
  • Full test suite: 136/136 passing.
  • Working tree grepped for signing-cert identity strings before each commit: clean.

🤖 Generated with Claude Code

logicallysynced and others added 2 commits June 10, 2026 20:23
…(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>
@logicallysynced logicallysynced self-assigned this Jun 10, 2026
logicallysynced and others added 19 commits June 11, 2026 16:48
…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.
Applied directly on chromatics-4.x, same pattern as the
nick-fields/retry v4 bump that superseded PR #203 - Dependabot's
PRs target master, which this branch replaces wholesale via PR #193.
… 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).
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.
@logicallysynced
logicallysynced merged commit 03869a1 into master Jul 7, 2026
5 of 7 checks passed
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