Skip to main content

s3lightfixes/
light_processing.rs

1use palette::{FromColor, GetHue, Hsv, IntoColor, SetHue, rgb::Srgb};
2use tes3::esp::{EditorId, LightFlags};
3
4use crate::{CustomLightData, LightConfig};
5
6// TES3 stores these values as integers, while lightfixes intentionally exposes multipliers as
7// floats. The casts are the conversion boundary between the file format and user-authored math.
8#[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// Same boundary as `scaled_u32`, but TES3 light duration is signed.
18#[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// RGB channels are TES3 u8 values. Multipliers are user math; clamp so exciting configs do not wrap
24// bright red into suspiciously dark red. Rendering bugs are bad enough without integer cosplay.
25#[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// User-provided fixed durations are floats to share parser machinery with multipliers, but TES3
35// stores the result as an integer duration.
36#[allow(clippy::cast_possible_truncation)]
37fn fixed_duration_to_i32(duration: f32) -> i32 {
38    duration as i32
39}
40
41/// Given a `LightData` reference from an ESP light,
42/// returns the HSV version and whether it is colored or not (for the global modifier).
43#[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}