1use palette::{FromColor, GetHue, Hsv, IntoColor, SetHue, rgb::Srgb};
2use tes3::esp::{EditorId, LightFlags};
3
4use crate::{CustomLightData, LightConfig};
5
6#[allow(
9 clippy::cast_possible_truncation,
10 clippy::cast_precision_loss,
11 clippy::cast_sign_loss
12)]
13fn scaled_u32(value: u32, multiplier: f32) -> u32 {
14 (value as f32 * multiplier).max(0.0) as u32
15}
16
17#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
19fn scaled_i32(value: i32, multiplier: f32) -> i32 {
20 (value as f32 * multiplier) as i32
21}
22
23#[allow(
26 clippy::cast_possible_truncation,
27 clippy::cast_precision_loss,
28 clippy::cast_sign_loss
29)]
30fn scaled_u8(value: u8, multiplier: f32) -> u8 {
31 (f32::from(value) * multiplier).clamp(0.0, 255.0) as u8
32}
33
34#[allow(clippy::cast_possible_truncation)]
37fn fixed_duration_to_i32(duration: f32) -> i32 {
38 duration as i32
39}
40
41#[must_use]
44pub fn light_to_hsv(light_data: &tes3::esp::LightData) -> (Hsv, bool) {
45 let hsv = color_to_hsv(light_data.color);
46 let hue_degrees = hsv.get_hue().into_positive_degrees();
47
48 (hsv, !(14. ..=64.).contains(&hue_degrees))
49}
50
51fn color_to_hsv(color: [u8; 4]) -> Hsv {
52 let rgb: palette::rgb::Rgb = Srgb::new(color[0], color[1], color[2]).into_format();
53 Hsv::from_color(rgb)
54}
55
56fn replacement_for_light<'a>(
57 light_config: &'a LightConfig,
58 light_id: &str,
59) -> Option<&'a CustomLightData> {
60 light_config
61 .light_regexes
62 .iter()
63 .find_map(|(regex, light_data)| regex.is_match(light_id).then_some(light_data))
64}
65
66fn apply_hsv_replacement(
67 light_as_hsv: &mut Hsv,
68 replacement: &CustomLightData,
69 global_hue: f32,
70 global_saturation: f32,
71 global_value: f32,
72 use_global_fallbacks: bool,
73) {
74 if let Some(hue_mult) = replacement.hue_mult {
75 let new_hue = palette::RgbHue::from_degrees(light_as_hsv.hue.into_raw_degrees() * hue_mult);
76 light_as_hsv.set_hue(new_hue);
77 } else if let Some(fixed_hue) = replacement.hue {
78 let new_hue = palette::RgbHue::from_degrees(hue_degrees(fixed_hue));
79 light_as_hsv.set_hue(new_hue);
80 } else if use_global_fallbacks {
81 let new_hue =
82 palette::RgbHue::from_degrees(light_as_hsv.hue.into_raw_degrees() * global_hue);
83 light_as_hsv.set_hue(new_hue);
84 }
85
86 if let Some(saturation_mult) = replacement.saturation_mult {
87 light_as_hsv.saturation *= saturation_mult;
88 } else if let Some(fixed_saturation) = replacement.saturation {
89 light_as_hsv.saturation = fixed_saturation;
90 } else if use_global_fallbacks {
91 light_as_hsv.saturation *= global_saturation;
92 }
93
94 if let Some(value_mult) = replacement.value_mult {
95 light_as_hsv.value *= value_mult;
96 } else if let Some(fixed_value) = replacement.value {
97 light_as_hsv.value = fixed_value;
98 } else if use_global_fallbacks {
99 light_as_hsv.value *= global_value;
100 }
101}
102
103#[allow(clippy::cast_precision_loss)]
104fn hue_degrees(hue: u32) -> f32 {
105 hue.clamp(0, 360) as f32
106}
107
108fn apply_plain_hsv_adjustment(
109 light_as_hsv: &mut Hsv,
110 global_hue: f32,
111 global_saturation: f32,
112 global_value: f32,
113) {
114 let new_hue = palette::RgbHue::from_degrees(light_as_hsv.hue.into_raw_degrees() * global_hue);
115
116 light_as_hsv.set_hue(new_hue);
117 light_as_hsv.saturation *= global_saturation;
118 light_as_hsv.value *= global_value;
119}
120
121fn apply_rgb_multipliers(color: &mut [u8; 4], replacement: &CustomLightData) {
122 if let Some(red_mult) = replacement.red_mult {
123 color[0] = scaled_u8(color[0], red_mult);
124 }
125 if let Some(green_mult) = replacement.green_mult {
126 color[1] = scaled_u8(color[1], green_mult);
127 }
128 if let Some(blue_mult) = replacement.blue_mult {
129 color[2] = scaled_u8(color[2], blue_mult);
130 }
131}
132
133pub fn process_light(light_config: &LightConfig, light: &mut tes3::esp::Light) -> Vec<String> {
134 let original_data = light.data.clone();
135
136 if light_config.disable_negative_lights && light.data.flags.contains(LightFlags::NEGATIVE) {
137 light.data.flags.remove(LightFlags::NEGATIVE);
138 light.data.radius = 0;
139 light.data.color = [0, 0, 0, 0];
140 return light_changes(&original_data, &light.data);
141 }
142
143 if light_config.disable_flickering {
144 light
145 .data
146 .flags
147 .remove(LightFlags::FLICKER | LightFlags::FLICKER_SLOW);
148 }
149
150 if light_config.disable_pulse {
151 light
152 .data
153 .flags
154 .remove(LightFlags::PULSE | LightFlags::PULSE_SLOW);
155 }
156
157 let light_id = light.editor_id_ascii_lowercase();
158 let (mut light_as_hsv, is_colored) = light_to_hsv(&light.data);
159 let replacement_light_data = replacement_for_light(light_config, &light_id);
160
161 let (global_radius, global_hue, global_saturation, global_value) = if is_colored {
162 (
163 light_config.colored_radius,
164 light_config.colored_hue,
165 light_config.colored_saturation,
166 light_config.colored_value,
167 )
168 } else {
169 (
170 light_config.standard_radius,
171 light_config.standard_hue,
172 light_config.standard_saturation,
173 light_config.standard_value,
174 )
175 };
176
177 if let Some(replacement) = replacement_light_data {
178 let use_global_fallbacks = replacement.color.is_none();
179 if let Some(fixed_color) = replacement.color {
180 light_as_hsv = color_to_hsv(fixed_color);
181 }
182
183 apply_hsv_replacement(
184 &mut light_as_hsv,
185 replacement,
186 global_hue,
187 global_saturation,
188 global_value,
189 use_global_fallbacks,
190 );
191
192 if let Some(duration_mult) = replacement.duration_mult {
193 light.data.time = scaled_i32(light.data.time, duration_mult);
194 } else if let Some(fixed_duration) = replacement.duration {
195 light.data.time = fixed_duration_to_i32(fixed_duration);
196 } else {
197 light.data.time = scaled_i32(light.data.time, light_config.duration_mult);
198 }
199
200 if let Some(radius_mult) = replacement.radius_mult {
201 light.data.radius = scaled_u32(light.data.radius, radius_mult);
202 } else if let Some(fixed_radius) = replacement.radius {
203 light.data.radius = fixed_radius;
204 } else {
205 light.data.radius = scaled_u32(light.data.radius, global_radius);
206 }
207
208 if let Some(flag) = &replacement.flag {
209 light.data.flags = flag.to_esp_flag();
210 }
211 } else {
212 apply_plain_hsv_adjustment(
213 &mut light_as_hsv,
214 global_hue,
215 global_saturation,
216 global_value,
217 );
218
219 light.data.radius = scaled_u32(light.data.radius, global_radius);
220 light.data.time = scaled_i32(light.data.time, light_config.duration_mult);
221 }
222
223 if let Some(replacement) = replacement_light_data {
224 let rgb8_color: Srgb<u8> = <Hsv as IntoColor<Srgb>>::into_color(light_as_hsv).into_format();
225 light.data.color = [rgb8_color.red, rgb8_color.green, rgb8_color.blue, 0];
226 apply_rgb_multipliers(&mut light.data.color, replacement);
227 } else {
228 let rgb8_color: Srgb<u8> = <Hsv as IntoColor<Srgb>>::into_color(light_as_hsv).into_format();
229 light.data.color = [rgb8_color.red, rgb8_color.green, rgb8_color.blue, 0];
230 }
231
232 light_changes(&original_data, &light.data)
233}
234
235fn light_changes(original: &tes3::esp::LightData, modified: &tes3::esp::LightData) -> Vec<String> {
236 let mut changes = Vec::new();
237
238 if original.color != modified.color {
239 changes.push(format!(
240 "color {:?} -> {:?}",
241 original.color, modified.color
242 ));
243 }
244
245 if original.radius != modified.radius {
246 changes.push(format!("radius {} -> {}", original.radius, modified.radius));
247 }
248
249 if original.time != modified.time {
250 changes.push(format!("duration {} -> {}", original.time, modified.time));
251 }
252
253 if original.flags != modified.flags {
254 changes.push(format!(
255 "flags {:?} -> {:?}",
256 original.flags, modified.flags
257 ));
258 }
259
260 changes
261}
262
263#[cfg(test)]
264mod tests {
265 use regex::Regex;
266 use tes3::esp::{Light, LightData, LightFlags, ObjectFlags};
267
268 use super::*;
269 use crate::light_override::LightFlag;
270
271 fn rgb_from_hsv(hue: f32, saturation: f32, value: f32) -> [u8; 4] {
272 let hsv = Hsv::from_components((palette::RgbHue::from_degrees(hue), saturation, value));
273 let rgb8_color: Srgb<u8> = <Hsv as IntoColor<Srgb>>::into_color(hsv).into_format();
274
275 [rgb8_color.red, rgb8_color.green, rgb8_color.blue, 0]
276 }
277
278 fn light(id: &str, hue: f32, radius: u32, time: i32, flags: LightFlags) -> Light {
279 Light {
280 flags: ObjectFlags::default(),
281 id: id.to_owned(),
282 data: LightData {
283 radius,
284 time,
285 color: rgb_from_hsv(hue, 1.0, 1.0),
286 flags,
287 ..LightData::default()
288 },
289 ..Light::default()
290 }
291 }
292
293 fn config() -> LightConfig {
294 LightConfig {
295 disable_flickering: false,
296 disable_pulse: false,
297 disable_negative_lights: true,
298 standard_hue: 1.0,
299 standard_saturation: 1.0,
300 standard_value: 1.0,
301 standard_radius: 1.0,
302 colored_hue: 1.0,
303 colored_saturation: 1.0,
304 colored_value: 1.0,
305 colored_radius: 1.0,
306 duration_mult: 1.0,
307 ..LightConfig::default()
308 }
309 }
310
311 #[test]
312 fn negative_lights_are_zeroed_and_return_early() {
313 let mut light_config = config();
314 light_config.disable_flickering = true;
315 light_config.disable_pulse = true;
316 light_config.standard_radius = 100.0;
317 light_config.duration_mult = 100.0;
318 light_config.light_regexes.push((
319 Regex::new("negative").unwrap(),
320 CustomLightData {
321 radius: Some(777),
322 duration: Some(888.0),
323 ..CustomLightData::default()
324 },
325 ));
326
327 let mut light = light(
328 "negative_light",
329 30.0,
330 42,
331 13,
332 LightFlags::NEGATIVE | LightFlags::FLICKER | LightFlags::PULSE,
333 );
334
335 process_light(&light_config, &mut light);
336
337 assert!(!light.data.flags.contains(LightFlags::NEGATIVE));
338 assert!(light.data.flags.contains(LightFlags::FLICKER));
339 assert!(light.data.flags.contains(LightFlags::PULSE));
340 assert_eq!(light.data.radius, 0);
341 assert_eq!(light.data.time, 13);
342 assert_eq!(light.data.color, [0, 0, 0, 0]);
343 }
344
345 #[test]
346 fn negative_lights_are_processed_normally_when_not_disabled() {
347 let mut light_config = config();
348 light_config.disable_negative_lights = false;
349 light_config.standard_radius = 2.0;
350 light_config.duration_mult = 3.0;
351
352 let mut light = light("negative_light", 30.0, 42, 13, LightFlags::NEGATIVE);
353
354 process_light(&light_config, &mut light);
355
356 assert!(light.data.flags.contains(LightFlags::NEGATIVE));
357 assert_eq!(light.data.radius, 84);
358 assert_eq!(light.data.time, 39);
359 }
360
361 #[test]
362 fn disabling_flicker_and_pulse_removes_only_those_flags() {
363 let mut light_config = config();
364 light_config.disable_flickering = true;
365 light_config.disable_pulse = true;
366
367 let mut light = light(
368 "animated_light",
369 30.0,
370 100,
371 10,
372 LightFlags::FLICKER
373 | LightFlags::FLICKER_SLOW
374 | LightFlags::PULSE
375 | LightFlags::PULSE_SLOW
376 | LightFlags::FIRE,
377 );
378
379 process_light(&light_config, &mut light);
380
381 assert!(!light.data.flags.contains(LightFlags::FLICKER));
382 assert!(!light.data.flags.contains(LightFlags::FLICKER_SLOW));
383 assert!(!light.data.flags.contains(LightFlags::PULSE));
384 assert!(!light.data.flags.contains(LightFlags::PULSE_SLOW));
385 assert!(light.data.flags.contains(LightFlags::FIRE));
386 }
387
388 #[test]
389 fn light_to_hsv_classifies_orange_boundaries_as_standard() {
390 for (hue, expected_colored) in [(13.0, true), (14.0, false), (64.0, false), (65.0, true)] {
391 let light = light("classified", hue, 1, 1, LightFlags::default());
392 let (_, is_colored) = light_to_hsv(&light.data);
393
394 assert_eq!(is_colored, expected_colored, "hue {hue}");
395 }
396 }
397
398 #[test]
399 fn standard_and_colored_lights_use_their_own_global_multipliers() {
400 let mut light_config = config();
401 light_config.standard_radius = 2.0;
402 light_config.colored_radius = 3.0;
403 light_config.duration_mult = 4.0;
404
405 let mut standard = light("standard", 30.0, 10, 5, LightFlags::default());
406 let mut colored = light("colored", 180.0, 10, 5, LightFlags::default());
407
408 process_light(&light_config, &mut standard);
409 process_light(&light_config, &mut colored);
410
411 assert_eq!(standard.data.radius, 20);
412 assert_eq!(colored.data.radius, 30);
413 assert_eq!(standard.data.time, 20);
414 assert_eq!(colored.data.time, 20);
415 }
416
417 #[test]
418 fn matching_light_overrides_beat_globals_and_fall_back_per_field() {
419 let mut light_config = config();
420 light_config.standard_radius = 2.0;
421 light_config.duration_mult = 3.0;
422 light_config.light_regexes.push((
423 Regex::new("fixed").unwrap(),
424 CustomLightData {
425 radius: Some(123),
426 duration: Some(456.0),
427 color: Some([0, 128, 64, 0]),
428 ..CustomLightData::default()
429 },
430 ));
431 light_config.light_regexes.push((
432 Regex::new("partial").unwrap(),
433 CustomLightData {
434 radius: Some(321),
435 ..CustomLightData::default()
436 },
437 ));
438 light_config.light_regexes.push((
439 Regex::new("mult").unwrap(),
440 CustomLightData {
441 radius_mult: Some(5.0),
442 duration_mult: Some(7.0),
443 ..CustomLightData::default()
444 },
445 ));
446
447 let mut fixed = light("fixed_light", 30.0, 10, 10, LightFlags::default());
448 let mut partial = light("partial_light", 30.0, 10, 10, LightFlags::default());
449 let mut mult = light("mult_light", 30.0, 10, 10, LightFlags::default());
450
451 process_light(&light_config, &mut fixed);
452 process_light(&light_config, &mut partial);
453 process_light(&light_config, &mut mult);
454
455 assert_eq!(fixed.data.radius, 123);
456 assert_eq!(fixed.data.time, 456);
457 assert_eq!(fixed.data.color, [0, 128, 64, 0]);
458
459 assert_eq!(partial.data.radius, 321);
460 assert_eq!(partial.data.time, 30);
461
462 assert_eq!(mult.data.radius, 50);
463 assert_eq!(mult.data.time, 70);
464 }
465
466 #[test]
467 fn partial_legacy_hsv_overrides_are_still_applied_at_runtime() {
468 let mut light_config = config();
469 light_config.standard_hue = 1.0;
470 light_config.standard_saturation = 1.0;
471 light_config.standard_value = 1.0;
472 light_config.light_regexes.push((
473 Regex::new("legacy_partial").unwrap(),
474 CustomLightData {
475 hue: Some(180),
476 saturation: Some(0.5),
477 ..CustomLightData::default()
478 },
479 ));
480 let mut light = light("legacy_partial", 30.0, 10, 10, LightFlags::default());
481
482 process_light(&light_config, &mut light);
483
484 assert_eq!(light.data.color, rgb_from_hsv(180.0, 0.5, 1.0));
485 }
486
487 #[test]
488 fn first_matching_light_override_wins_and_flag_replacement_is_exact() {
489 let mut light_config = config();
490 light_config.standard_radius = 10.0;
491 light_config.light_regexes.push((
492 Regex::new("torch").unwrap(),
493 CustomLightData {
494 radius: Some(111),
495 flag: Some(LightFlag::PulseSlow),
496 ..CustomLightData::default()
497 },
498 ));
499 light_config.light_regexes.push((
500 Regex::new("torch_special").unwrap(),
501 CustomLightData {
502 radius: Some(222),
503 flag: Some(LightFlag::Flicker),
504 ..CustomLightData::default()
505 },
506 ));
507 let mut light = light(
508 "torch_special",
509 30.0,
510 10,
511 10,
512 LightFlags::FIRE | LightFlags::FLICKER,
513 );
514
515 process_light(&light_config, &mut light);
516
517 assert_eq!(light.data.radius, 111);
518 assert_eq!(light.data.flags, LightFlags::PULSE_SLOW);
519 }
520
521 #[test]
522 fn hsv_multiplier_overrides_apply_to_matching_lights() {
523 let mut light_config = config();
524 light_config.light_regexes.push((
525 Regex::new("hsv_mult").unwrap(),
526 CustomLightData {
527 hue_mult: Some(2.0),
528 saturation_mult: Some(0.5),
529 value_mult: Some(0.25),
530 ..CustomLightData::default()
531 },
532 ));
533 let mut light = light("hsv_mult_light", 30.0, 10, 10, LightFlags::default());
534
535 process_light(&light_config, &mut light);
536
537 assert_eq!(light.data.color, rgb_from_hsv(60.0, 0.5, 0.25));
538 }
539
540 #[test]
541 fn rgb_multipliers_apply_after_hsv_adjustments() {
542 let mut light_config = config();
543 light_config.light_regexes.push((
544 Regex::new("rgb_after_hsv").unwrap(),
545 CustomLightData {
546 hue_mult: Some(2.0),
547 saturation_mult: Some(0.5),
548 value_mult: Some(0.25),
549 red_mult: Some(0.5),
550 green_mult: Some(2.0),
551 blue_mult: Some(-1.0),
552 ..CustomLightData::default()
553 },
554 ));
555 let mut light = light("rgb_after_hsv_light", 30.0, 10, 10, LightFlags::default());
556
557 process_light(&light_config, &mut light);
558
559 let mut expected = rgb_from_hsv(60.0, 0.5, 0.25);
560 expected[0] = scaled_u8(expected[0], 0.5);
561 expected[1] = scaled_u8(expected[1], 2.0);
562 expected[2] = 0;
563 assert_eq!(light.data.color, expected);
564 }
565
566 #[test]
567 fn fixed_rgb_gets_rgb_multipliers() {
568 let mut light_config = config();
569 light_config.standard_hue = 10.0;
570 light_config.standard_saturation = 0.0;
571 light_config.standard_value = 0.0;
572 light_config.light_regexes.push((
573 Regex::new("fixed_rgb").unwrap(),
574 CustomLightData {
575 color: Some([100, 80, 60, 0]),
576 red_mult: Some(3.0),
577 green_mult: Some(0.5),
578 blue_mult: Some(1.0),
579 ..CustomLightData::default()
580 },
581 ));
582 let mut light = light("fixed_rgb_light", 30.0, 10, 10, LightFlags::default());
583
584 process_light(&light_config, &mut light);
585
586 assert_eq!(light.data.color, [255, 40, 60, 0]);
587 }
588
589 #[test]
590 fn fixed_rgb_is_base_color_for_hsv_adjustments() {
591 let mut light_config = config();
592 light_config.standard_hue = 10.0;
593 light_config.standard_saturation = 0.0;
594 light_config.standard_value = 0.0;
595 light_config.light_regexes.push((
596 Regex::new("fixed_rgb_hsv").unwrap(),
597 CustomLightData {
598 color: Some([255, 0, 0, 0]),
599 hue: Some(120),
600 green_mult: Some(0.5),
601 ..CustomLightData::default()
602 },
603 ));
604 let mut light = light("fixed_rgb_hsv_light", 30.0, 10, 10, LightFlags::default());
605
606 process_light(&light_config, &mut light);
607
608 assert_eq!(light.data.color, [0, 127, 0, 0]);
609 }
610
611 #[test]
612 fn negative_radius_multipliers_clamp_to_zero_instead_of_wrapping() {
613 let mut light_config = config();
614 light_config.standard_radius = -2.0;
615 light_config.light_regexes.push((
616 Regex::new("override").unwrap(),
617 CustomLightData {
618 radius_mult: Some(-3.0),
619 ..CustomLightData::default()
620 },
621 ));
622 let mut global = light("global", 30.0, 10, 10, LightFlags::default());
623 let mut overridden = light("override", 30.0, 10, 10, LightFlags::default());
624
625 process_light(&light_config, &mut global);
626 process_light(&light_config, &mut overridden);
627
628 assert_eq!(global.data.radius, 0);
629 assert_eq!(overridden.data.radius, 0);
630 }
631}