Skip to content
Open
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5f3fd4d
feat: add BrushWalker effect for dynamic color trails
suromark Mar 25, 2026
9cf7ed2
Reverted a mistaken edit in platformio.ini
suromark Mar 25, 2026
7309b3d
Added audio-reactive version of BrushWalker FX
suromark Mar 26, 2026
9436775
Undid auto-formatting affecting other parts of user_fx.cpp
suromark Mar 26, 2026
dd4e134
Some cleanup on BrushWalker FX
suromark Mar 26, 2026
3454cec
Added missing context in description
suromark Mar 26, 2026
acf489f
Refactored BrushWalker after suggestions and critique
suromark Mar 27, 2026
f1b7727
Cleaned up into a more self-contained OO style with Gemini
suromark Mar 27, 2026
8254352
Reworked brushwalker fx code styling, fixed mistaken edit of platform…
suromark Mar 27, 2026
53ee3b9
Fixed wrong index for reading from audioreactive; added peak-spawn in…
suromark Mar 27, 2026
b26d1a6
Fixed a logical error in the spawn logic
suromark Mar 27, 2026
9ea1423
Lowered the respawn cutoff
suromark Mar 27, 2026
6d4a431
Reworked storage for effect data
suromark Mar 28, 2026
6ac7f14
Merge branch 'wled:main' into BrushWalkerFX
suromark Apr 9, 2026
ce27414
Merge branch 'main' into pr/5452
softhack007 Apr 10, 2026
724cb8c
Implemented suggested code and style fixes from AI review, added comm…
suromark Apr 11, 2026
8145caf
Added comment reference to documentation for AI parser for clarificat…
suromark Apr 12, 2026
5102e06
Slider labels better description
suromark Apr 12, 2026
53f57db
Merge branch 'main' into BrushWalkerFX
softhack007 Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 250 additions & 2 deletions usermods/user_fx/user_fx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,253 @@ static void mode_morsecode(void) {
}
static const char _data_FX_MODE_MORSECODE[] PROGMEM = "Morse Code@Speed,,,,Color mode,Color by Word,Punctuation,EndOfMessage;;!;1;sx=192,c3=8,o1=1,o2=1";

/**********************************************************************************************
* BrushWalker
* Uses palette for the trails and background color as fade target.
* Walkers spawn randomly from the edges and move in straight lines across the
* matrix, changing color as they go, leaving (fading) trails of "painted" color
* behind them. Tries to avoid spawning new walkers too close to existing ones
* to prevent overcrowding and create a more visually appealing distribution.
* Inspired by the concept of "Matrix", but with a more vivid, undirected and
* colorful twist. First implementation 2019 with FastLED, but without WLED
* framework. Redesigned and adapted for WLED in 2026, with parts from
* claude.ai, Gemini, chatGPT, Grok. Controls: Speed, Spawn Chance, Fade Rate,
* Palette Step, Max Walkers (up to 32)
*
* @author suromark 2019,2026
*
*/
namespace BrushWalkerFX {
Comment on lines +1260 to +1277

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add the repo-standard AI block marker.

This header already credits the AI tools used, but the repo rule for large AI-authored sections is the standard // AI: marker around the whole block, not just prose inside the comment.

♻️ Suggested change
+// AI: below section was generated by an AI
 /**********************************************************************************************
  * BrushWalker
  * Uses palette for the trails and background color as fade target.
  * Walkers spawn randomly from the edges and move in straight lines across the
@@
 }  // end namespace BrushWalkerFX
+// AI: above section was generated by an AI

As per coding guidelines, "Document attribution of inspiration, knowledge, and sources for AI-generated code. Mark larger blocks of AI-generated code with a comment: // AI: below section was generated by an AI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usermods/user_fx/user_fx.cpp` around lines 1260 - 1276, The file's
BrushWalkerFX section is missing the repo-standard AI block marker; wrap or
prepend the large AI-influenced block (namespace BrushWalkerFX and its header
comment) with the single-line marker comment "// AI: below section was generated
by an AI" so the block is clearly attributed per guideline; locate the namespace
BrushWalkerFX declaration and its preceding comment header and add the marker
immediately above (or directly inside) that header to cover the entire
AI-influenced section.


const uint8_t absoluteMaxWalkers = 32;

struct Walker {
bool active;
int16_t x, y;
int8_t dx, dy;
uint8_t colorIndex;

/**
* @brief Helper; clears internal state of a Walker
*
*/
void reset() {
active = false;
x = y = dx = dy = colorIndex = 0;
}

/**
* @brief Generates a random start position and direction
*
* @param cols
* @param rows
* @return
*/
void makeCandidate(uint16_t cols, uint16_t rows) {
uint8_t side = hw_random8(4);
switch (side) {
case 0:
x = hw_random16(cols);
y = 0;
dx = 0;
dy = 1;
break; // top
case 1:
x = hw_random16(cols);
y = rows - 1;
dx = 0;
dy = -1;
break; // bottom
case 2:
x = 0;
y = hw_random16(rows);
dx = 1;
dy = 0;
break; // left
default:
x = cols - 1;
y = hw_random16(rows);
dx = -1;
dy = 0;
break; // right
}
colorIndex = hw_random8();
active = false;
}

/**
* @brief Helper to check if the current Walker's new route is too close to an existing one
*
* @param walkers
* @param maxCount
* @param minGap
* @return true
* @return false
*/
bool hasConflict(const Walker* walkers, uint8_t maxCount, uint8_t minGap) {
for (uint8_t i = 0; i < maxCount; i++) {
const Walker& other = walkers[i];
if (!other.active || &other == this) continue;
if (other.dx != dx || other.dy != dy) continue;
if (dy == 0 && other.y == y && abs(other.x - x) < minGap) return true;
if (dx == 0 && other.x == x && abs(other.y - y) < minGap) return true;
}
return false;
}

/**
* @brief Moves the walker and paints the pixel
*
* @param cols
* @param rows
* @param palStep
*/
void update(uint16_t cols, uint16_t rows, uint8_t palStep) {
if (!active) return;

uint32_t c = (SEGMENT.palette > 0)
? SEGMENT.color_from_palette(colorIndex, false, PALETTE_SOLID_WRAP, 0)
: SEGCOLOR(0);

SEGMENT.setPixelColorXY(x, y, c);

x += dx;
y += dy;
colorIndex += palStep;

if (x < 0 || y < 0 || x >= (int16_t)cols || y >= (int16_t)rows) {
active = false;
}
}
};

/**
* @brief Container data structure, walkers plus other persistent data
*
*/
struct SegmentData {
Walker walkers[absoluteMaxWalkers];
uint32_t triggerGate;
};

/**
* @brief Helper: search a free Walker slot; if found, try up to 2 times to find valid random start coordinates
*
* @param walkers
* @param maxCount
* @param cols
* @param rows
*/
static void trySpawn(Walker* walkers, uint8_t maxCount, uint16_t cols, uint16_t rows) {
int freeSlot = -1;
for (uint8_t i = 0; i < maxCount; i++) {
if (!walkers[i].active) {
freeSlot = i;
break;
}
}
if (freeSlot < 0) return;

const uint8_t minGap = 8;
for (uint8_t retry = 0; retry < 2; retry++) {
walkers[freeSlot].makeCandidate(cols, rows);
if (!walkers[freeSlot].hasConflict(walkers, maxCount, minGap)) {
walkers[freeSlot].active = true;
break;
}
}

return;
}
/**
* @brief The main processing loop, supports two trigger modes: 0 = fully random, 1 = plus considering audioreactive peak signal
*
* @param triggerMode
*/
static void mode_brushwalker_core(uint8_t triggerMode) {
if (!strip.isMatrix || !SEGMENT.is2D()) FX_FALLBACK_STATIC;

const uint16_t cols = SEG_W;
const uint16_t rows = SEG_H;

if (!SEGENV.allocateData(sizeof(SegmentData) ) ) FX_FALLBACK_STATIC;

SegmentData* data = reinterpret_cast<SegmentData*>(SEGENV.data);

if (SEGENV.call == 0) { // init values on first call
for (uint8_t i = 0; i < absoluteMaxWalkers; i++) data->walkers[i].reset();
data->triggerGate = strip.now;
SEGMENT.fill(SEGCOLOR(1));
SEGENV.step = strip.now;
}

// timing
uint16_t interval = 8 + ((255 - SEGMENT.speed) >> 1);
if (strip.now - SEGENV.step < interval) return;
SEGENV.step = strip.now;

uint8_t sensitivity = SEGMENT.intensity;
uint8_t fadeRate = SEGMENT.custom1 >> 1;
uint8_t palStep = SEGMENT.custom2 >> 4;
uint8_t maxWalkers = min((int)(1 + SEGMENT.custom3), (int)absoluteMaxWalkers);

SEGMENT.fadeToSecondaryBy(fadeRate);

// Trigger Logic
bool shouldSpawn = false;
if (triggerMode == 1) {
um_data_t* um_data;
if (!UsermodManager::getUMData(&um_data, USERMOD_ID_AUDIOREACTIVE))
um_data = simulateSound(SEGMENT.soundSim);
if ( ( um_data && *(uint8_t*)um_data->u_data[3] ) || hw_random8() < sensitivity )
shouldSpawn = true;
} else {
if (hw_random8() < sensitivity) shouldSpawn = true;
}

if (shouldSpawn) {
if( SEGENV.step > data->triggerGate ) {
data->triggerGate = SEGENV.step + 32; // de-clogging, avoid immediate respawn
trySpawn(data->walkers, maxWalkers, cols, rows);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
shouldSpawn = false;
}

// Object-Oriented Update Loop
for (uint8_t i = 0; i < maxWalkers; i++) {
data->walkers[i].update(cols, rows, palStep);
}
Comment on lines +1456 to +1484

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import ctypes

ABSOLUTE_MAX_WALKERS = 32
for custom3 in (0, 31, 32, 254, 255):
    wrapped = ctypes.c_uint8(1 + custom3).value
    max_walkers = min(wrapped, ABSOLUTE_MAX_WALKERS)
    print(f"custom3={custom3:3d} -> wrapped={wrapped:3d}, maxWalkers={max_walkers:2d}")
PY

Repository: wled/WLED

Length of output: 262


🏁 Script executed:

sed -n '1456,1484p' usermods/user_fx/user_fx.cpp | cat -n

Repository: wled/WLED

Length of output: 1252


🏁 Script executed:

# Find the walker struct definition
rg -A 10 'struct.*[Ww]alker' usermods/user_fx/user_fx.cpp | head -50

Repository: wled/WLED

Length of output: 519


🏁 Script executed:

# Check how SEGENV is used and if walkers persist
rg -B 5 -A 5 'data->walkers\[' usermods/user_fx/user_fx.cpp | head -80

Repository: wled/WLED

Length of output: 569


🏁 Script executed:

# Check the Walker::update() implementation
rg -A 15 'void.*update\(' usermods/user_fx/user_fx.cpp | grep -A 15 'class Walker' | head -40

Repository: wled/WLED

Length of output: 35


🏁 Script executed:

# Get the full update method for Walker
rg -B 2 -A 15 'update\(uint8_t cols' usermods/user_fx/user_fx.cpp

Repository: wled/WLED

Length of output: 35


🏁 Script executed:

# Search for Walker class and its update method
rg -A 25 'Walker {' usermods/user_fx/user_fx.cpp | head -100

Repository: wled/WLED

Length of output: 132


🏁 Script executed:

# Try finding update method differently
ast-grep --pattern 'void update($_) { $$$ }'

Repository: wled/WLED

Length of output: 591


🏁 Script executed:

# Search specifically in user_fx.cpp for the update method body
rg -A 20 'Walker.*update' usermods/user_fx/user_fx.cpp | head -60

Repository: wled/WLED

Length of output: 35


🏁 Script executed:

# Look for the struct Walker definition and its methods more carefully
rg -B 5 -A 40 'struct Walker' usermods/user_fx/user_fx.cpp | head -80

Repository: wled/WLED

Length of output: 914


🏁 Script executed:

# Get more of the Walker struct to find update method
rg -A 80 'struct Walker {' usermods/user_fx/user_fx.cpp | head -120

Repository: wled/WLED

Length of output: 146


🏁 Script executed:

# Get more of the Walker struct - escape the brace
rg -A 80 'struct Walker' usermods/user_fx/user_fx.cpp | head -150

Repository: wled/WLED

Length of output: 1864


🏁 Script executed:

# Find the update method in Walker - search for "void update"
rg -B 2 -A 20 'void update\(' usermods/user_fx/user_fx.cpp | grep -A 20 'Moves the walker'

Repository: wled/WLED

Length of output: 35


🏁 Script executed:

# Alternative: get line count and search more broadly
wc -l usermods/user_fx/user_fx.cpp

Repository: wled/WLED

Length of output: 86


🏁 Script executed:

# Search for "update" method after the comments about "Moves the walker and paints"
rg -A 25 'Moves the walker and paints the pixel' usermods/user_fx/user_fx.cpp

Repository: wled/WLED

Length of output: 632


Fix the walker-limit edge cases around maxWalkers.

Line 1456 narrows 1u + SEGMENT.custom3 before clamping, so custom3 == 255 wraps to 0 and maxWalkers becomes 0, disabling the effect entirely. Additionally, lowering the slider leaves higher-index walkers active in persistent SEGENV data, so they can reappear from stale positions if the cap is raised again.

Proposed fix
-  uint8_t maxWalkers = (uint8_t) min( (uint8_t) (1u + SEGMENT.custom3), (uint8_t)absoluteMaxWalkers);
+  uint16_t requestedWalkers = 1u + static_cast<uint16_t>(SEGMENT.custom3);
+  if (requestedWalkers > absoluteMaxWalkers) requestedWalkers = absoluteMaxWalkers;
+  uint8_t maxWalkers = static_cast<uint8_t>(requestedWalkers);
+  for (uint8_t i = maxWalkers; i < absoluteMaxWalkers; ++i) {
+    data->walkers[i].active = false;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uint8_t maxWalkers = (uint8_t) min( (uint8_t) (1u + SEGMENT.custom3), (uint8_t)absoluteMaxWalkers);
SEGMENT.fadeToSecondaryBy(fadeRate);
// Trigger Logic
bool shouldSpawn = false;
if (triggerMode == 1) {
um_data_t* um_data;
if (!UsermodManager::getUMData(&um_data, USERMOD_ID_AUDIOREACTIVE))
um_data = simulateSound(SEGMENT.soundSim);
if ( ( um_data && um_data->u_size > 3 && *(uint8_t*)um_data->u_data[3] ) || hw_random8() < sensitivity )
shouldSpawn = true;
} else {
if (hw_random8() < sensitivity) shouldSpawn = true;
}
if (shouldSpawn) {
if (strip.now - data->triggerGate < 32) { // uint32 overflow safe
// nope, we're still within deadtime zone
} else {
data->triggerGate = SEGENV.step; // de-clogging, avoid immediate respawn
trySpawn(data->walkers, maxWalkers, cols, rows);
}
}
// Object-Oriented Update Loop
for (uint8_t i = 0; i < maxWalkers; i++) {
data->walkers[i].update(cols, rows, palStep);
}
uint16_t requestedWalkers = 1u + static_cast<uint16_t>(SEGMENT.custom3);
if (requestedWalkers > absoluteMaxWalkers) requestedWalkers = absoluteMaxWalkers;
uint8_t maxWalkers = static_cast<uint8_t>(requestedWalkers);
for (uint8_t i = maxWalkers; i < absoluteMaxWalkers; ++i) {
data->walkers[i].active = false;
}
SEGMENT.fadeToSecondaryBy(fadeRate);
// Trigger Logic
bool shouldSpawn = false;
if (triggerMode == 1) {
um_data_t* um_data;
if (!UsermodManager::getUMData(&um_data, USERMOD_ID_AUDIOREACTIVE))
um_data = simulateSound(SEGMENT.soundSim);
if ( ( um_data && um_data->u_size > 3 && *(uint8_t*)um_data->u_data[3] ) || hw_random8() < sensitivity )
shouldSpawn = true;
} else {
if (hw_random8() < sensitivity) shouldSpawn = true;
}
if (shouldSpawn) {
if (strip.now - data->triggerGate < 32) { // uint32 overflow safe
// nope, we're still within deadtime zone
} else {
data->triggerGate = SEGENV.step; // de-clogging, avoid immediate respawn
trySpawn(data->walkers, maxWalkers, cols, rows);
}
}
// Object-Oriented Update Loop
for (uint8_t i = 0; i < maxWalkers; i++) {
data->walkers[i].update(cols, rows, palStep);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usermods/user_fx/user_fx.cpp` around lines 1456 - 1484, Compute maxWalkers
using a wider integer to avoid unsigned wrap (e.g. uint16_t tmp = 1u +
(uint16_t)SEGMENT.custom3; then clamp: uint8_t maxWalkers = (uint8_t)min(tmp,
(uint16_t)absoluteMaxWalkers)), and ensure it is at least 1 if that’s required
for the effect; after computing the new max, deactivate any higher-index
persistent walkers so reducing the slider can’t leave stale walkers alive
(iterate i from maxWalkers to absoluteMaxWalkers-1 and clear/mark inactive those
entries in data->walkers, e.g. call an existing reset/kill method or set an
alive flag), keeping references to maxWalkers, SEGMENT.custom3,
absoluteMaxWalkers, data->walkers and trySpawn to locate the relevant code.

}

} // end namespace BrushWalkerFX

/**
* @brief Brushwalker mode with random spawning only - if random chance based on
* sensitivity slider hits, a new walker will spawn
*
*/
static void mode_brushwalker(void) { BrushWalkerFX::mode_brushwalker_core(0); }
// The metadata string consists of up to five sections, separated by semicolons:
// <Effect parameters>;<Colors>;<Palette>;<Flags>;<Defaults>
static const char _data_FX_MODE_BRUSHWALKER[] PROGMEM =
"Brush Walker@!,Spawn,Fade,Palette Step,Max "
"Walkers;,!;!;2;pal=11,sx=204,ix=64,c1=48,c2=24,c3=4";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose speed in both metadata strings.

mode_brushwalker_core() still uses SEGMENT.speed at Line 1441, but both descriptors start with @!, which hides slider 1. That leaves the effect speed stuck at the default unless users edit JSON or presets manually.

🎛️ Suggested change
 static const char _data_FX_MODE_BRUSHWALKER[] PROGMEM =
-    "Brush Walker@!,Spawn,Fade,Palette Step,Max "
+    "Brush Walker@Speed,Spawn,Fade,Palette Step,Max "
     "Walkers;,!;!;2;pal=11,sx=204,ix=64,c1=48,c2=24,c3=4";
@@
 static const char _data_FX_MODE_BRUSHWALKER_AR[] PROGMEM =
-    "Brush Walker AR@!,Sensitivity,Fade,Palette Step,Max "
+    "Brush Walker AR@Speed,Sensitivity,Fade,Palette Step,Max "
     "Walkers;,!;!;2v;pal=11,sx=204,ix=64,c1=48,c2=24,c3=4";

Also applies to: 1501-1503

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usermods/user_fx/user_fx.cpp` around lines 1488 - 1490, The effect
descriptors for Brush Walker hide slider 1 by starting with "@!", which prevents
mode_brushwalker_core() from receiving SEGMENT.speed changes; update both
_data_FX_MODE_BRUSHWALKER metadata strings (the two occurrences around the shown
diff) to expose the speed field by replacing the leading "@!" with an explicit
speed token (e.g., "@speed!") so the first slider becomes visible and
SEGMENT.speed is driven from the UI/presets.


/**
* @brief Brushwalker mode with audioreactive triggering - if a peak is
* detected, or if random chance based on sensitivity slider hits, a new walker
* will spawn
*/
static void mode_brushwalker_ar(void) {
BrushWalkerFX::mode_brushwalker_core(1);
}

static const char _data_FX_MODE_BRUSHWALKER_AR[] PROGMEM =
"Brush Walker AR@!,Sensitivity,Fade,Palette Step,Max "
"Walkers;,!;!;2v;pal=11,sx=204,ix=64,c1=48,c2=24,c3=4";

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// END BrushWalker

/////////////////////
// UserMod Class //
Expand All @@ -1272,14 +1519,15 @@ class UserFxUsermod : public Usermod {
strip.addEffect(255, &mode_2D_magma, _data_FX_MODE_2D_MAGMA);
strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS);
strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whitespace change should be reverted.

////////////////////////////////////////
// add your effect function(s) here //
////////////////////////////////////////

// use id=255 for all custom user FX (the final id is assigned when adding the effect)

// strip.addEffect(255, &mode_your_effect, _data_FX_MODE_YOUR_EFFECT);
strip.addEffect(255, &mode_brushwalker, _data_FX_MODE_BRUSHWALKER);
strip.addEffect(255, &mode_brushwalker_ar, _data_FX_MODE_BRUSHWALKER_AR);
// strip.addEffect(255, &mode_your_effect2, _data_FX_MODE_YOUR_EFFECT2);
// strip.addEffect(255, &mode_your_effect3, _data_FX_MODE_YOUR_EFFECT3);
}
Expand Down
Loading