use crate::effect::{validate_buffer, validate_num_leds, Effect, EffectError, MAX_LEDS};
use rgb::RGB8;
const DEFAULT_SEED: u32 = 0x1234_5678;
fn xorshift32(mut x: u32) -> u32 {
debug_assert!(x != 0, "xorshift32 state must be non-zero");
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x
}
pub(crate) fn fire_color(heat: u8) -> RGB8 {
let t = heat as u16;
if t < 85 {
RGB8::new((t * 180 / 84) as u8, 0, 0)
} else if t < 170 {
let s = t - 85;
RGB8::new((180 + s * 75 / 84) as u8, (s * 200 / 84) as u8, 0)
} else {
let s = t - 170;
RGB8::new(255, (200 + s * 55 / 85) as u8, 0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FireEffect {
num_leds: usize,
heat: [u8; MAX_LEDS],
cooling: u8,
sparking: u8,
rng_state: u32,
initial_seed: u32,
wrap: bool,
base_range: usize,
}
impl FireEffect {
pub fn new(num_leds: usize) -> Result<Self, EffectError> {
validate_num_leds(num_leds)?;
Ok(Self {
num_leds,
heat: [0u8; MAX_LEDS],
cooling: 55,
sparking: 120,
rng_state: DEFAULT_SEED,
initial_seed: DEFAULT_SEED,
wrap: false,
base_range: num_leds.min(3),
})
}
pub fn with_cooling(mut self, cooling: u8) -> Self {
self.cooling = cooling;
self
}
pub fn with_sparking(mut self, sparking: u8) -> Self {
self.sparking = sparking;
self
}
pub fn with_wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
pub fn with_base_range(mut self, range: usize) -> Self {
self.base_range = range.clamp(1, self.num_leds);
self
}
pub fn with_seed(mut self, seed: u32) -> Self {
let s = seed.max(1);
self.rng_state = s;
self.initial_seed = s;
self
}
pub fn set_cooling(&mut self, cooling: u8) {
self.cooling = cooling;
}
pub fn set_sparking(&mut self, sparking: u8) {
self.sparking = sparking;
}
pub fn num_leds(&self) -> usize {
self.num_leds
}
pub fn cooling(&self) -> u8 {
self.cooling
}
pub fn sparking(&self) -> u8 {
self.sparking
}
fn rng_byte(&mut self) -> u8 {
self.rng_state = xorshift32(self.rng_state);
self.rng_state as u8
}
pub fn current(&self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
validate_buffer(buffer, self.num_leds)?;
for (led, heat) in buffer.iter_mut().zip(self.heat[..self.num_leds].iter()) {
*led = fire_color(*heat);
}
Ok(())
}
pub fn update(&mut self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
validate_buffer(buffer, self.num_leds)?;
let n = self.num_leds;
let cooling_max = ((self.cooling as u16 * 10 / n as u16) + 2).min(255) as u8;
for i in 0..n {
let cool = self.rng_byte() % cooling_max;
self.heat[i] = self.heat[i].saturating_sub(cool);
}
if self.wrap && n >= 2 {
let mut snapshot = [0u8; MAX_LEDS];
snapshot[..n].copy_from_slice(&self.heat[..n]);
for i in (0..n).rev() {
let a = snapshot[(i + n - 1) % n] as u16;
let b = snapshot[(i + n - 2) % n] as u16;
self.heat[i] = ((a + a + b) / 3) as u8;
}
} else if n >= 3 {
for i in (2..n).rev() {
let a = self.heat[i - 1] as u16;
let b = self.heat[i - 2] as u16;
self.heat[i] = ((a + a + b) / 3) as u8;
}
}
if self.sparking == 255 || (self.sparking > 0 && self.rng_byte() < self.sparking) {
debug_assert!(self.base_range >= 1, "base_range invariant: must be >= 1");
let range = self.base_range as u16;
let limit = (256 / range) * range;
let y = loop {
let r = self.rng_byte() as u16;
if r < limit {
break (r % range) as usize;
}
};
let boost = self.rng_byte().saturating_add(100);
self.heat[y] = self.heat[y].saturating_add(boost);
}
for (led, heat) in buffer.iter_mut().zip(self.heat[..n].iter()) {
*led = fire_color(*heat);
}
Ok(())
}
pub fn reset(&mut self) {
self.heat = [0u8; MAX_LEDS];
self.rng_state = self.initial_seed;
}
}
impl Effect for FireEffect {
fn update(&mut self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
FireEffect::update(self, buffer)
}
fn current(&self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
FireEffect::current(self, buffer)
}
fn reset(&mut self) {
FireEffect::reset(self);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::effect::MAX_LEDS;
#[test]
fn test_new_with_zero_leds_returns_error() {
assert_eq!(FireEffect::new(0).unwrap_err(), EffectError::ZeroLeds);
}
#[test]
fn test_new_with_valid_leds_succeeds() {
let effect = FireEffect::new(12).unwrap();
assert_eq!(effect.num_leds(), 12);
}
#[test]
fn test_new_with_too_many_leds_returns_error() {
assert!(matches!(
FireEffect::new(MAX_LEDS + 1).unwrap_err(),
EffectError::TooManyLeds { .. }
));
}
#[test]
fn test_buffer_too_small_returns_error() {
let effect = FireEffect::new(12).unwrap();
let mut buffer = [RGB8::default(); 8];
assert_eq!(
effect.current(&mut buffer).unwrap_err(),
EffectError::BufferTooSmall {
required: 12,
actual: 8,
}
);
}
#[test]
fn test_initial_state_all_black() {
let effect = FireEffect::new(12).unwrap();
let mut buffer = [RGB8::new(1, 1, 1); 12]; effect.current(&mut buffer).unwrap();
for (i, led) in buffer.iter().enumerate() {
assert_eq!(*led, RGB8::default(), "LED {i} should be black on init");
}
}
#[test]
fn test_sparking_zero_never_ignites() {
let mut effect = FireEffect::new(12).unwrap().with_sparking(0);
let mut buffer = [RGB8::default(); 12];
for _ in 0..50 {
effect.update(&mut buffer).unwrap();
}
for (i, led) in buffer.iter().enumerate() {
assert_eq!(
*led,
RGB8::default(),
"LED {i} should stay black with sparking=0"
);
}
}
#[test]
fn test_sparking_255_lights_base() {
let mut effect = FireEffect::new(12).unwrap().with_sparking(255);
let mut buffer = [RGB8::default(); 12];
effect.update(&mut buffer).unwrap();
assert!(
buffer.iter().any(|led| *led != RGB8::default()),
"at least one LED should be lit with sparking=255"
);
}
#[test]
fn test_current_does_not_advance() {
let mut effect = FireEffect::new(8).unwrap().with_sparking(255);
let mut buffer = [RGB8::default(); 8];
for _ in 0..5 {
effect.update(&mut buffer).unwrap();
}
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
effect.current(&mut buf1).unwrap();
effect.current(&mut buf2).unwrap();
assert_eq!(buf1, buf2, "current() must not change state");
}
#[test]
fn test_update_changes_state_over_time() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_sparking(255)
.with_cooling(0);
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
effect.update(&mut buf1).unwrap();
for _ in 0..5 {
effect.update(&mut buf2).unwrap();
}
assert_ne!(buf1, buf2, "state must evolve over multiple updates");
}
#[test]
fn test_reset_clears_all_leds() {
let mut effect = FireEffect::new(12).unwrap().with_sparking(255);
let mut buffer = [RGB8::default(); 12];
for _ in 0..10 {
effect.update(&mut buffer).unwrap();
}
assert!(
buffer.iter().any(|led| *led != RGB8::default()),
"some LEDs should be lit before reset"
);
effect.reset();
effect.current(&mut buffer).unwrap();
for (i, led) in buffer.iter().enumerate() {
assert_eq!(*led, RGB8::default(), "LED {i} should be black after reset");
}
}
#[test]
fn test_reset_restores_rng_sequence() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_sparking(255)
.with_seed(0xDEAD_BEEF);
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
for _ in 0..5 {
effect.update(&mut buf1).unwrap();
}
effect.reset();
for _ in 0..5 {
effect.update(&mut buf2).unwrap();
}
assert_eq!(
buf1, buf2,
"same seed must replay an identical sequence after reset"
);
}
#[test]
fn test_set_cooling_does_not_reset_heat() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_sparking(255)
.with_cooling(0);
let mut buffer = [RGB8::default(); 8];
effect.update(&mut buffer).unwrap();
assert!(
buffer.iter().any(|led| *led != RGB8::default()),
"heat should exist after sparking=255 update"
);
effect.set_cooling(10);
effect.current(&mut buffer).unwrap();
assert!(
buffer.iter().any(|led| *led != RGB8::default()),
"heat should survive set_cooling"
);
}
#[test]
fn test_set_sparking_does_not_reset_heat() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_sparking(255)
.with_cooling(0);
let mut buffer = [RGB8::default(); 8];
effect.update(&mut buffer).unwrap();
effect.set_sparking(0);
effect.current(&mut buffer).unwrap();
assert!(
buffer.iter().any(|led| *led != RGB8::default()),
"heat should survive set_sparking"
);
}
#[test]
fn test_fire_color_black_at_zero() {
assert_eq!(fire_color(0), RGB8::new(0, 0, 0));
}
#[test]
fn test_fire_color_yellow_at_max() {
assert_eq!(fire_color(255), RGB8::new(255, 255, 0));
}
#[test]
fn test_fire_color_gradient_midpoints() {
let c85 = fire_color(85);
assert_eq!(c85, RGB8::new(180, 0, 0), "heat=85 should be dark red");
let c170 = fire_color(170);
assert_eq!(
c170,
RGB8::new(255, 200, 0),
"heat=170 should be orange-yellow"
);
}
#[test]
fn test_fire_color_brightness_monotone() {
let mut prev_sum: u32 = 0;
for heat in 0u8..=255 {
let c = fire_color(heat);
let sum = c.r as u32 + c.g as u32 + c.b as u32;
assert!(
sum >= prev_sum,
"brightness decreased at heat={heat}: prev={prev_sum}, now={sum}"
);
prev_sum = sum;
}
}
#[test]
fn test_snapshot_fixed_seed() {
let mut effect = FireEffect::new(4)
.unwrap()
.with_sparking(255)
.with_cooling(0)
.with_seed(0xABCD_1234);
let mut buf = [RGB8::default(); 4];
for _ in 0..3 {
effect.update(&mut buf).unwrap();
}
let expected = [
RGB8::new(0, 0, 0),
RGB8::new(255, 255, 0),
RGB8::new(255, 255, 0),
RGB8::new(255, 218, 0),
];
assert_eq!(
buf, expected,
"snapshot mismatch — gradient or PRNG changed"
);
}
#[test]
fn test_cooling_getter() {
let effect = FireEffect::new(8).unwrap().with_cooling(42);
assert_eq!(effect.cooling(), 42);
}
#[test]
fn test_sparking_getter() {
let effect = FireEffect::new(8).unwrap().with_sparking(200);
assert_eq!(effect.sparking(), 200);
}
#[test]
fn test_single_led_ring() {
let mut effect = FireEffect::new(1)
.unwrap()
.with_sparking(255)
.with_cooling(0);
let mut buffer = [RGB8::default(); 1];
effect.update(&mut buffer).unwrap();
assert_ne!(
buffer[0],
RGB8::default(),
"single-LED ring should light after sparking=255 update"
);
}
#[test]
fn test_trait_object_update() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_sparking(255)
.with_cooling(0);
let effect_ref: &mut dyn Effect = &mut effect;
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
effect_ref.update(&mut buf1).unwrap();
for _ in 0..5 {
effect_ref.update(&mut buf2).unwrap();
}
assert_ne!(buf1, buf2, "state must evolve between trait-object updates");
}
#[test]
fn test_trait_reset_path() {
let mut effect = FireEffect::new(8).unwrap().with_sparking(255).with_seed(42);
let mut buf_before = [RGB8::default(); 8];
let mut buf_after = [RGB8::default(); 8];
let effect_ref: &mut dyn Effect = &mut effect;
effect_ref.update(&mut buf_before).unwrap();
effect_ref.reset();
effect_ref.update(&mut buf_after).unwrap();
assert_eq!(
buf_before, buf_after,
"trait reset must replay the same sequence"
);
}
#[test]
fn test_wrap_default_is_false() {
let effect = FireEffect::new(12).unwrap();
assert!(!effect.wrap, "wrap should default to false");
}
#[test]
fn test_wrap_enabled_propagates_tip_to_base() {
const N: usize = 8;
let mut wrap_effect = FireEffect::new(N)
.unwrap()
.with_sparking(0)
.with_cooling(0)
.with_wrap(true);
let mut linear_effect = FireEffect::new(N)
.unwrap()
.with_sparking(0)
.with_cooling(0)
.with_wrap(false);
wrap_effect.heat[N - 1] = 240;
linear_effect.heat[N - 1] = 240;
let mut wrap_buf = [RGB8::default(); N];
let mut linear_buf = [RGB8::default(); N];
wrap_effect.update(&mut wrap_buf).unwrap();
linear_effect.update(&mut linear_buf).unwrap();
assert!(
wrap_effect.heat[0] > 100,
"wrap mode must propagate heat[n-1] into heat[0]; got {}",
wrap_effect.heat[0]
);
assert_eq!(
linear_effect.heat[0], 0,
"linear mode must leave heat[0] anchored at zero"
);
}
#[test]
fn test_wrap_single_led_no_panic() {
let mut effect = FireEffect::new(1)
.unwrap()
.with_sparking(255)
.with_cooling(0)
.with_wrap(true);
let mut buffer = [RGB8::default(); 1];
for _ in 0..5 {
effect.update(&mut buffer).unwrap();
}
}
#[test]
fn test_wrap_two_leds_no_panic() {
let mut effect = FireEffect::new(2)
.unwrap()
.with_sparking(255)
.with_wrap(true);
let mut buffer = [RGB8::default(); 2];
for _ in 0..5 {
effect.update(&mut buffer).unwrap();
}
}
#[test]
fn test_base_range_default_small_ring() {
let effect = FireEffect::new(12).unwrap();
assert_eq!(
effect.base_range, 3,
"default for n=12 should be min(12,3)=3"
);
}
#[test]
fn test_base_range_default_tiny_ring() {
let effect = FireEffect::new(2).unwrap();
assert_eq!(effect.base_range, 2, "default for n=2 should be min(2,3)=2");
}
#[test]
fn test_base_range_default_single_led() {
let effect = FireEffect::new(1).unwrap();
assert_eq!(effect.base_range, 1, "default for n=1 should be 1");
}
#[test]
fn test_with_base_range_sets_value() {
let effect = FireEffect::new(12).unwrap().with_base_range(6);
assert_eq!(effect.base_range, 6);
}
#[test]
fn test_with_base_range_clamps_to_num_leds() {
let effect = FireEffect::new(8).unwrap().with_base_range(20);
assert_eq!(effect.base_range, 8, "should clamp to num_leds");
}
#[test]
fn test_with_base_range_clamps_zero_to_one() {
let effect = FireEffect::new(8).unwrap().with_base_range(0);
assert_eq!(effect.base_range, 1, "should clamp 0 to 1");
}
#[test]
fn test_wide_base_range_ignites_beyond_index_2() {
let mut effect = FireEffect::new(8)
.unwrap()
.with_base_range(8)
.with_sparking(255)
.with_cooling(0);
let mut buffer = [RGB8::default(); 8];
let mut saw_high_index = false;
for _ in 0..50 {
effect.update(&mut buffer).unwrap();
if buffer[3..].iter().any(|led| *led != RGB8::default()) {
saw_high_index = true;
break;
}
}
assert!(
saw_high_index,
"with base_range=8, sparks should reach indices beyond 2"
);
}
#[test]
fn test_oversized_buffer_accepted() {
let sentinel = RGB8::new(0xDE, 0xAD, 0xFF);
let effect = FireEffect::new(4).unwrap();
let mut buffer = [sentinel; 8];
effect.current(&mut buffer).unwrap();
for i in 4..8 {
assert_eq!(
buffer[i], sentinel,
"LED {} beyond num_leds must not be modified",
i
);
}
}
}