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 const fn flag(flags: LightFlags) -> LightFlag {
312 LightFlag::from_esp_flags(flags)
313 }
314
315 #[test]
316 fn negative_lights_are_zeroed_and_return_early() {
317 let mut light_config = config();
318 light_config.disable_flickering = true;
319 light_config.disable_pulse = true;
320 light_config.standard_radius = 100.0;
321 light_config.duration_mult = 100.0;
322 light_config.light_regexes.push((
323 Regex::new("negative").unwrap(),
324 CustomLightData {
325 radius: Some(777),
326 duration: Some(888.0),
327 ..CustomLightData::default()
328 },
329 ));
330
331 let mut light = light(
332 "negative_light",
333 30.0,
334 42,
335 13,
336 LightFlags::NEGATIVE | LightFlags::FLICKER | LightFlags::PULSE,
337 );
338
339 process_light(&light_config, &mut light);
340
341 assert!(!light.data.flags.contains(LightFlags::NEGATIVE));
342 assert!(light.data.flags.contains(LightFlags::FLICKER));
343 assert!(light.data.flags.contains(LightFlags::PULSE));
344 assert_eq!(light.data.radius, 0);
345 assert_eq!(light.data.time, 13);
346 assert_eq!(light.data.color, [0, 0, 0, 0]);
347 }
348
349 #[test]
350 fn negative_lights_are_processed_normally_when_not_disabled() {
351 let mut light_config = config();
352 light_config.disable_negative_lights = false;
353 light_config.standard_radius = 2.0;
354 light_config.duration_mult = 3.0;
355
356 let mut light = light("negative_light", 30.0, 42, 13, LightFlags::NEGATIVE);
357
358 process_light(&light_config, &mut light);
359
360 assert!(light.data.flags.contains(LightFlags::NEGATIVE));
361 assert_eq!(light.data.radius, 84);
362 assert_eq!(light.data.time, 39);
363 }
364
365 #[test]
366 fn disabling_flicker_and_pulse_removes_only_those_flags() {
367 let mut light_config = config();
368 light_config.disable_flickering = true;
369 light_config.disable_pulse = true;
370
371 let mut light = light(
372 "animated_light",
373 30.0,
374 100,
375 10,
376 LightFlags::FLICKER
377 | LightFlags::FLICKER_SLOW
378 | LightFlags::PULSE
379 | LightFlags::PULSE_SLOW
380 | LightFlags::FIRE,
381 );
382
383 process_light(&light_config, &mut light);
384
385 assert!(!light.data.flags.contains(LightFlags::FLICKER));
386 assert!(!light.data.flags.contains(LightFlags::FLICKER_SLOW));
387 assert!(!light.data.flags.contains(LightFlags::PULSE));
388 assert!(!light.data.flags.contains(LightFlags::PULSE_SLOW));
389 assert!(light.data.flags.contains(LightFlags::FIRE));
390 }
391
392 #[test]
393 fn light_to_hsv_classifies_orange_boundaries_as_standard() {
394 for (hue, expected_colored) in [(13.0, true), (14.0, false), (64.0, false), (65.0, true)] {
395 let light = light("classified", hue, 1, 1, LightFlags::default());
396 let (_, is_colored) = light_to_hsv(&light.data);
397
398 assert_eq!(is_colored, expected_colored, "hue {hue}");
399 }
400 }
401
402 #[test]
403 fn standard_and_colored_lights_use_their_own_global_multipliers() {
404 let mut light_config = config();
405 light_config.standard_radius = 2.0;
406 light_config.colored_radius = 3.0;
407 light_config.duration_mult = 4.0;
408
409 let mut standard = light("standard", 30.0, 10, 5, LightFlags::default());
410 let mut colored = light("colored", 180.0, 10, 5, LightFlags::default());
411
412 process_light(&light_config, &mut standard);
413 process_light(&light_config, &mut colored);
414
415 assert_eq!(standard.data.radius, 20);
416 assert_eq!(colored.data.radius, 30);
417 assert_eq!(standard.data.time, 20);
418 assert_eq!(colored.data.time, 20);
419 }
420
421 #[test]
422 fn matching_light_overrides_beat_globals_and_fall_back_per_field() {
423 let mut light_config = config();
424 light_config.standard_radius = 2.0;
425 light_config.duration_mult = 3.0;
426 light_config.light_regexes.push((
427 Regex::new("fixed").unwrap(),
428 CustomLightData {
429 radius: Some(123),
430 duration: Some(456.0),
431 color: Some([0, 128, 64, 0]),
432 ..CustomLightData::default()
433 },
434 ));
435 light_config.light_regexes.push((
436 Regex::new("partial").unwrap(),
437 CustomLightData {
438 radius: Some(321),
439 ..CustomLightData::default()
440 },
441 ));
442 light_config.light_regexes.push((
443 Regex::new("mult").unwrap(),
444 CustomLightData {
445 radius_mult: Some(5.0),
446 duration_mult: Some(7.0),
447 ..CustomLightData::default()
448 },
449 ));
450
451 let mut fixed = light("fixed_light", 30.0, 10, 10, LightFlags::default());
452 let mut partial = light("partial_light", 30.0, 10, 10, LightFlags::default());
453 let mut mult = light("mult_light", 30.0, 10, 10, LightFlags::default());
454
455 process_light(&light_config, &mut fixed);
456 process_light(&light_config, &mut partial);
457 process_light(&light_config, &mut mult);
458
459 assert_eq!(fixed.data.radius, 123);
460 assert_eq!(fixed.data.time, 456);
461 assert_eq!(fixed.data.color, [0, 128, 64, 0]);
462
463 assert_eq!(partial.data.radius, 321);
464 assert_eq!(partial.data.time, 30);
465
466 assert_eq!(mult.data.radius, 50);
467 assert_eq!(mult.data.time, 70);
468 }
469
470 #[test]
471 fn partial_legacy_hsv_overrides_are_still_applied_at_runtime() {
472 let mut light_config = config();
473 light_config.standard_hue = 1.0;
474 light_config.standard_saturation = 1.0;
475 light_config.standard_value = 1.0;
476 light_config.light_regexes.push((
477 Regex::new("legacy_partial").unwrap(),
478 CustomLightData {
479 hue: Some(180),
480 saturation: Some(0.5),
481 ..CustomLightData::default()
482 },
483 ));
484 let mut light = light("legacy_partial", 30.0, 10, 10, LightFlags::default());
485
486 process_light(&light_config, &mut light);
487
488 assert_eq!(light.data.color, rgb_from_hsv(180.0, 0.5, 1.0));
489 }
490
491 #[test]
492 fn first_matching_light_override_wins_and_flag_replacement_is_exact() {
493 let mut light_config = config();
494 light_config.standard_radius = 10.0;
495 light_config.light_regexes.push((
496 Regex::new("torch").unwrap(),
497 CustomLightData {
498 radius: Some(111),
499 flag: Some(flag(LightFlags::PULSE_SLOW)),
500 ..CustomLightData::default()
501 },
502 ));
503 light_config.light_regexes.push((
504 Regex::new("torch_special").unwrap(),
505 CustomLightData {
506 radius: Some(222),
507 flag: Some(flag(LightFlags::FLICKER)),
508 ..CustomLightData::default()
509 },
510 ));
511 let mut light = light(
512 "torch_special",
513 30.0,
514 10,
515 10,
516 LightFlags::CAN_CARRY | LightFlags::FIRE | LightFlags::FLICKER,
517 );
518
519 process_light(&light_config, &mut light);
520
521 assert_eq!(light.data.radius, 111);
522 assert_eq!(light.data.flags, LightFlags::PULSE_SLOW);
523 }
524
525 #[test]
526 fn none_flag_clears_all_flags() {
527 let mut light_config = config();
528 light_config.light_regexes.push((
529 Regex::new("torch").unwrap(),
530 CustomLightData {
531 flag: Some(flag(LightFlags::empty())),
532 ..CustomLightData::default()
533 },
534 ));
535 let mut light = light(
536 "torch",
537 30.0,
538 10,
539 10,
540 LightFlags::CAN_CARRY | LightFlags::FIRE | LightFlags::FLICKER | LightFlags::PULSE_SLOW,
541 );
542
543 process_light(&light_config, &mut light);
544
545 assert_eq!(light.data.flags, LightFlags::empty());
546 }
547
548 #[test]
549 fn flag_replacement_strips_source_bits_not_named_by_user() {
550 let mut light_config = config();
551 light_config.light_regexes.push((
552 Regex::new("torch").unwrap(),
553 CustomLightData {
554 flag: Some(flag(LightFlags::PULSE)),
555 ..CustomLightData::default()
556 },
557 ));
558 let unknown_flag = LightFlags::from_bits_retain(0x200);
559 let mut light = light(
560 "torch",
561 30.0,
562 10,
563 10,
564 unknown_flag | LightFlags::CAN_CARRY | LightFlags::FLICKER,
565 );
566
567 process_light(&light_config, &mut light);
568
569 assert_eq!(light.data.flags, LightFlags::PULSE);
570 }
571
572 #[test]
573 fn flag_override_can_insert_carryable_flags() {
574 let mut light_config = config();
575 light_config.light_regexes.push((
576 Regex::new("torch").unwrap(),
577 CustomLightData {
578 flag: Some(flag(LightFlags::CAN_CARRY | LightFlags::PULSE_SLOW)),
579 ..CustomLightData::default()
580 },
581 ));
582 let mut light = light(
583 "torch",
584 30.0,
585 10,
586 10,
587 LightFlags::FIRE | LightFlags::FLICKER,
588 );
589
590 process_light(&light_config, &mut light);
591
592 assert_eq!(
593 light.data.flags,
594 LightFlags::CAN_CARRY | LightFlags::PULSE_SLOW
595 );
596 }
597
598 #[test]
599 fn hsv_multiplier_overrides_apply_to_matching_lights() {
600 let mut light_config = config();
601 light_config.light_regexes.push((
602 Regex::new("hsv_mult").unwrap(),
603 CustomLightData {
604 hue_mult: Some(2.0),
605 saturation_mult: Some(0.5),
606 value_mult: Some(0.25),
607 ..CustomLightData::default()
608 },
609 ));
610 let mut light = light("hsv_mult_light", 30.0, 10, 10, LightFlags::default());
611
612 process_light(&light_config, &mut light);
613
614 assert_eq!(light.data.color, rgb_from_hsv(60.0, 0.5, 0.25));
615 }
616
617 #[test]
618 fn rgb_multipliers_apply_after_hsv_adjustments() {
619 let mut light_config = config();
620 light_config.light_regexes.push((
621 Regex::new("rgb_after_hsv").unwrap(),
622 CustomLightData {
623 hue_mult: Some(2.0),
624 saturation_mult: Some(0.5),
625 value_mult: Some(0.25),
626 red_mult: Some(0.5),
627 green_mult: Some(2.0),
628 blue_mult: Some(-1.0),
629 ..CustomLightData::default()
630 },
631 ));
632 let mut light = light("rgb_after_hsv_light", 30.0, 10, 10, LightFlags::default());
633
634 process_light(&light_config, &mut light);
635
636 let mut expected = rgb_from_hsv(60.0, 0.5, 0.25);
637 expected[0] = scaled_u8(expected[0], 0.5);
638 expected[1] = scaled_u8(expected[1], 2.0);
639 expected[2] = 0;
640 assert_eq!(light.data.color, expected);
641 }
642
643 #[test]
644 fn fixed_rgb_gets_rgb_multipliers() {
645 let mut light_config = config();
646 light_config.standard_hue = 10.0;
647 light_config.standard_saturation = 0.0;
648 light_config.standard_value = 0.0;
649 light_config.light_regexes.push((
650 Regex::new("fixed_rgb").unwrap(),
651 CustomLightData {
652 color: Some([100, 80, 60, 0]),
653 red_mult: Some(3.0),
654 green_mult: Some(0.5),
655 blue_mult: Some(1.0),
656 ..CustomLightData::default()
657 },
658 ));
659 let mut light = light("fixed_rgb_light", 30.0, 10, 10, LightFlags::default());
660
661 process_light(&light_config, &mut light);
662
663 assert_eq!(light.data.color, [255, 40, 60, 0]);
664 }
665
666 #[test]
667 fn fixed_rgb_is_base_color_for_hsv_adjustments() {
668 let mut light_config = config();
669 light_config.standard_hue = 10.0;
670 light_config.standard_saturation = 0.0;
671 light_config.standard_value = 0.0;
672 light_config.light_regexes.push((
673 Regex::new("fixed_rgb_hsv").unwrap(),
674 CustomLightData {
675 color: Some([255, 0, 0, 0]),
676 hue: Some(120),
677 green_mult: Some(0.5),
678 ..CustomLightData::default()
679 },
680 ));
681 let mut light = light("fixed_rgb_hsv_light", 30.0, 10, 10, LightFlags::default());
682
683 process_light(&light_config, &mut light);
684
685 assert_eq!(light.data.color, [0, 127, 0, 0]);
686 }
687
688 #[test]
689 fn negative_radius_multipliers_clamp_to_zero_instead_of_wrapping() {
690 let mut light_config = config();
691 light_config.standard_radius = -2.0;
692 light_config.light_regexes.push((
693 Regex::new("override").unwrap(),
694 CustomLightData {
695 radius_mult: Some(-3.0),
696 ..CustomLightData::default()
697 },
698 ));
699 let mut global = light("global", 30.0, 10, 10, LightFlags::default());
700 let mut overridden = light("override", 30.0, 10, 10, LightFlags::default());
701
702 process_light(&light_config, &mut global);
703 process_light(&light_config, &mut overridden);
704
705 assert_eq!(global.data.radius, 0);
706 assert_eq!(overridden.data.radius, 0);
707 }
708}