Skip to main content

s3lightfixes/
light_config.rs

1use std::{
2    fmt,
3    fs::{File, read_dir, read_to_string},
4    io::{self, Write},
5    marker::PhantomData,
6    path::PathBuf,
7};
8
9use ordered_hash_map::OrderedHashMap;
10use serde::{
11    Deserialize, Deserializer, Serialize, Serializer,
12    de::{MapAccess, Visitor},
13};
14
15use crate::{
16    CustomCellAmbient, CustomLightData, DEFAULT_CONFIG_NAME, LightArgs, default, notification_box,
17    to_io_error,
18};
19
20pub fn deserialize_ordered_hash_map<'de, D, K, V>(
21    deserializer: D,
22) -> Result<OrderedHashMap<K, V>, D::Error>
23where
24    D: Deserializer<'de>,
25    K: Deserialize<'de> + Eq + std::hash::Hash,
26    V: Deserialize<'de>,
27{
28    struct OrderedHashMapVisitor<K, V> {
29        marker: PhantomData<fn() -> OrderedHashMap<K, V>>,
30    }
31
32    impl<K, V> OrderedHashMapVisitor<K, V> {
33        fn new() -> Self {
34            OrderedHashMapVisitor {
35                marker: PhantomData,
36            }
37        }
38    }
39
40    impl<'de, K, V> Visitor<'de> for OrderedHashMapVisitor<K, V>
41    where
42        K: Deserialize<'de> + Eq + std::hash::Hash,
43        V: Deserialize<'de>,
44    {
45        type Value = OrderedHashMap<K, V>;
46
47        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
48            formatter.write_str("a map")
49        }
50
51        fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
52        where
53            M: MapAccess<'de>,
54        {
55            let mut map = OrderedHashMap::with_capacity(access.size_hint().unwrap_or(0));
56
57            while let Some((key, value)) = access.next_entry()? {
58                map.insert(key, value);
59            }
60
61            Ok(map)
62        }
63    }
64
65    deserializer.deserialize_map(OrderedHashMapVisitor::new())
66}
67
68pub fn serialize_ordered_hash_map<S, K, V>(
69    map: &OrderedHashMap<K, V>,
70    serializer: S,
71) -> Result<S::Ok, S::Error>
72where
73    S: Serializer,
74    K: Serialize,
75    V: Serialize,
76{
77    use serde::ser::SerializeMap;
78
79    let mut ser_map = serializer.serialize_map(Some(map.len()))?;
80    for (k, v) in map {
81        ser_map.serialize_entry(k, v)?;
82    }
83    ser_map.end()
84}
85
86#[derive(Debug, Deserialize, Serialize)]
87// This struct is the public TOML schema. Grouping the booleans into enum wrappers would either
88// change the config format or add serde indirection that exists only to satisfy clippy.
89#[allow(clippy::struct_excessive_bools)]
90pub struct LightConfig {
91    /// This parameter is DANGEROUS
92    /// It's only meant to be used with vtastek's experimental shaders for openmw 0.47
93    /// <https://discord.com/channels/260439894298460160/718892786157617163/966468825321177148>
94    #[serde(default)]
95    pub disable_interior_sun: bool,
96
97    #[serde(default = "default::disable_flicker")]
98    pub disable_flickering: bool,
99
100    #[serde(default = "default::disable_pulse")]
101    pub disable_pulse: bool,
102
103    #[serde(default = "default::disable_negative_lights")]
104    pub disable_negative_lights: bool,
105
106    #[serde(default = "default::auto_enable")]
107    pub auto_enable: bool,
108
109    #[serde(default)]
110    pub no_notifications: bool,
111
112    #[serde(default)]
113    pub debug: bool,
114
115    #[serde(default)]
116    pub dry_run: bool,
117
118    #[serde(default)]
119    pub validate_config: bool,
120
121    #[serde(default = "default::standard_hue")]
122    pub standard_hue: f32,
123
124    #[serde(default = "default::standard_saturation")]
125    pub standard_saturation: f32,
126
127    #[serde(default = "default::standard_value")]
128    pub standard_value: f32,
129
130    #[serde(default = "default::standard_radius")]
131    pub standard_radius: f32,
132
133    #[serde(default = "default::colored_hue")]
134    pub colored_hue: f32,
135
136    #[serde(default = "default::colored_saturation")]
137    pub colored_saturation: f32,
138
139    #[serde(default = "default::colored_value")]
140    pub colored_value: f32,
141
142    #[serde(default = "default::colored_radius")]
143    pub colored_radius: f32,
144
145    #[serde(default = "default::duration_mult")]
146    pub duration_mult: f32,
147
148    #[serde(default = "default::excluded_plugins")]
149    pub excluded_plugins: Vec<String>,
150
151    #[serde(default)]
152    pub excluded_ids: Vec<String>,
153
154    #[serde(
155        default,
156        serialize_with = "serialize_ordered_hash_map",
157        deserialize_with = "deserialize_ordered_hash_map"
158    )]
159    pub light_overrides: OrderedHashMap<String, CustomLightData>,
160
161    #[serde(
162        default,
163        serialize_with = "serialize_ordered_hash_map",
164        deserialize_with = "deserialize_ordered_hash_map"
165    )]
166    pub ambient_overrides: OrderedHashMap<String, CustomCellAmbient>,
167
168    pub output_dir: Option<PathBuf>,
169
170    #[serde(default)]
171    pub save_config: bool,
172
173    #[serde(skip)]
174    pub excluded_id_regexes: Vec<regex::Regex>,
175    #[serde(skip)]
176    pub excluded_plugin_regexes: Vec<regex::Regex>,
177    #[serde(skip)]
178    pub light_regexes: Vec<(regex::Regex, CustomLightData)>,
179    #[serde(skip)]
180    pub ambient_regexes: Vec<(regex::Regex, CustomCellAmbient)>,
181}
182
183/// Primarily exists to provide default implementations
184/// for field values
185impl LightConfig {
186    fn find(root_path: &PathBuf) -> Result<PathBuf, io::Error> {
187        read_dir(root_path)?
188            .filter_map(std::result::Result::ok)
189            .find(|entry| entry.file_name().eq_ignore_ascii_case(DEFAULT_CONFIG_NAME))
190            .map(|entry| entry.path())
191            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Light config not found"))
192    }
193
194    fn load(
195        user_config_path: &std::path::Path,
196        early_no_notifications: bool,
197    ) -> io::Result<(Self, bool)> {
198        let Ok(config_path) = Self::find(&user_config_path.to_path_buf()) else {
199            return Ok((LightConfig::default(), true));
200        };
201
202        let config_contents = read_to_string(config_path)?;
203        let config = toml::from_str(&config_contents).unwrap_or_else(|error| {
204            notification_box(
205                "Failed to read light config!",
206                &format!("Lightconfig.toml couldn't be read: {error}"),
207                early_no_notifications,
208            );
209            std::process::exit(256);
210        });
211
212        Ok((config, false))
213    }
214
215    fn apply_scalar_args(&mut self, light_args: &mut LightArgs) {
216        Self::overwrite_if_some([
217            (&mut self.standard_hue, &mut light_args.standard_hue),
218            (
219                &mut self.standard_saturation,
220                &mut light_args.standard_saturation,
221            ),
222            (&mut self.standard_value, &mut light_args.standard_value),
223            (&mut self.standard_radius, &mut light_args.standard_radius),
224            (&mut self.colored_hue, &mut light_args.colored_hue),
225            (
226                &mut self.colored_saturation,
227                &mut light_args.colored_saturation,
228            ),
229            (&mut self.colored_value, &mut light_args.colored_value),
230            (&mut self.colored_radius, &mut light_args.colored_radius),
231            (&mut self.duration_mult, &mut light_args.duration_mult),
232        ]);
233    }
234
235    fn apply_bool_args(&mut self, light_args: &LightArgs) {
236        Self::overwrite_if_some([
237            (
238                &mut self.disable_pulse,
239                &mut light_args.disable_pulse.clone(),
240            ),
241            (
242                &mut self.disable_flickering,
243                &mut light_args.disable_flickering.clone(),
244            ),
245            (
246                &mut self.disable_negative_lights,
247                &mut light_args.disable_negative_lights.clone(),
248            ),
249            (
250                &mut self.auto_enable,
251                &mut light_args.auto_enable.then_some(true),
252            ),
253            (
254                &mut self.no_notifications,
255                &mut light_args.no_notifications.then_some(true),
256            ),
257            (&mut self.debug, &mut light_args.debug.then_some(true)),
258        ]);
259
260        if let Some(dry_run) = light_args.dry_run {
261            self.dry_run = dry_run;
262            if dry_run {
263                self.validate_config = false;
264            }
265        }
266
267        if let Some(validate_config) = light_args.validate_config {
268            self.validate_config = validate_config;
269            if validate_config {
270                self.dry_run = false;
271            }
272        }
273    }
274
275    fn effective_non_writing_modes(&self, light_args: &LightArgs) -> io::Result<(bool, bool)> {
276        let mut dry_run = self.dry_run;
277        let mut validate_config = self.validate_config;
278
279        if let Some(cli_dry_run) = light_args.dry_run {
280            dry_run = cli_dry_run;
281            if cli_dry_run {
282                validate_config = false;
283            }
284        }
285
286        if let Some(cli_validate_config) = light_args.validate_config {
287            validate_config = cli_validate_config;
288            if cli_validate_config {
289                dry_run = false;
290            }
291        }
292
293        if dry_run && validate_config {
294            return Err(io::Error::new(
295                io::ErrorKind::InvalidInput,
296                "dry_run and validate_config cannot both be true",
297            ));
298        }
299
300        Ok((dry_run, validate_config))
301    }
302
303    fn apply_collection_args(&mut self, light_args: &mut LightArgs) {
304        self.excluded_ids
305            .extend(std::mem::take(&mut light_args.excluded_ids));
306        self.excluded_plugins
307            .extend(std::mem::take(&mut light_args.excluded_plugins));
308        self.light_overrides
309            .extend(std::mem::take(&mut light_args.light_overrides));
310        self.ambient_overrides
311            .extend(std::mem::take(&mut light_args.ambient_overrides));
312    }
313
314    fn configure_output_dir(
315        &mut self,
316        output_dir: Option<PathBuf>,
317        openmw_config: &openmw_config::OpenMWConfiguration,
318    ) -> io::Result<()> {
319        if let Some(out_dir) = output_dir {
320            if out_dir.is_dir() {
321                self.output_dir = Some(out_dir);
322                return Ok(());
323            }
324
325            notification_box(
326                "Can't find output location!",
327                &format!(
328                    "WARNING: The requested output path {} does not exist! Terminating.",
329                    out_dir.display()
330                ),
331                self.no_notifications,
332            );
333            std::process::exit(1);
334        }
335
336        if self.output_dir.is_none() {
337            self.output_dir = Some(match openmw_config.data_local() {
338                Some(path) => path.parsed().to_owned(),
339                None => std::env::current_dir()?,
340            });
341        }
342
343        Ok(())
344    }
345
346    fn save_to_user_config(&self, user_config_path: &std::path::Path) -> io::Result<()> {
347        let config_serialized = toml::to_string_pretty(self).map_err(to_io_error)?;
348        let config_path = user_config_path.join(DEFAULT_CONFIG_NAME);
349        let mut config_file = File::create(config_path)?;
350        write!(config_file, "{config_serialized}")
351    }
352
353    fn save_to_user_config_without_runtime_flags(
354        &mut self,
355        user_config_path: &std::path::Path,
356        persisted_no_notifications: bool,
357        persisted_debug: bool,
358    ) -> io::Result<()> {
359        let runtime_no_notifications =
360            std::mem::replace(&mut self.no_notifications, persisted_no_notifications);
361        let runtime_debug = std::mem::replace(&mut self.debug, persisted_debug);
362        let result = self.save_to_user_config(user_config_path);
363        self.no_notifications = runtime_no_notifications;
364        self.debug = runtime_debug;
365
366        result
367    }
368
369    fn compile_regexes(&mut self) -> io::Result<()> {
370        let mut errors = Vec::new();
371
372        for id in std::mem::take(&mut self.excluded_ids) {
373            match regex::Regex::new(&id) {
374                Ok(pattern) => self.excluded_id_regexes.push(pattern),
375                Err(error) => {
376                    let message = format!("Couldn't compile excluded id regex: {id}: {error}");
377                    notification_box(
378                        "Invalid excluded id regex!",
379                        &message,
380                        self.no_notifications,
381                    );
382                    errors.push(message);
383                }
384            }
385        }
386
387        for id in std::mem::take(&mut self.excluded_plugins) {
388            match regex::Regex::new(&id) {
389                Ok(pattern) => self.excluded_plugin_regexes.push(pattern),
390                Err(error) => {
391                    let message = format!("Couldn't compile excluded plugin regex: {id}: {error}");
392                    notification_box(
393                        "Invalid excluded plugin regex!",
394                        &message,
395                        self.no_notifications,
396                    );
397                    errors.push(message);
398                }
399            }
400        }
401
402        for (id, light_data) in std::mem::take(&mut self.light_overrides) {
403            match regex::Regex::new(&id) {
404                Ok(pattern) => self.light_regexes.push((pattern, light_data)),
405                Err(error) => {
406                    let message = format!("Couldn't compile light override regex: {id}: {error}");
407                    notification_box("Invalid light override!", &message, self.no_notifications);
408                    errors.push(message);
409                }
410            }
411        }
412
413        for (id, light_data) in std::mem::take(&mut self.ambient_overrides) {
414            match regex::Regex::new(&id) {
415                Ok(pattern) => self.ambient_regexes.push((pattern, light_data)),
416                Err(error) => {
417                    let message = format!("Couldn't compile ambient override regex: {id}: {error}");
418                    notification_box("Invalid ambient override!", &message, self.no_notifications);
419                    errors.push(message);
420                }
421            }
422        }
423
424        if errors.is_empty() {
425            Ok(())
426        } else {
427            Err(io::Error::new(
428                io::ErrorKind::InvalidInput,
429                errors.join("\n"),
430            ))
431        }
432    }
433
434    fn overwrite_if_some<'a, I, T>(pairs: I)
435    where
436        // (&mut T, &mut Option<T>) for every element
437        I: IntoIterator<Item = (&'a mut T, &'a mut Option<T>)>,
438        // Restrict to primitive / scalar types
439        T: Copy + Default + 'a,
440    {
441        for (field, maybe_val) in pairs {
442            if let Some(v) = maybe_val {
443                *field = std::mem::take(v); // move value across, leave default behind
444            }
445        }
446    }
447
448    /// Gives back the lightconfig adjacent to openmw.cfg when called
449    /// `use_classic` dictates whether or not a fixed radius of 2.0 will be used on orange-y lights
450    /// and whether or not to disable interior sunlight
451    /// the latter field is not de/serializable and can only be used via the --classic argument
452    ///
453    /// # Errors
454    ///
455    /// Returns filesystem errors encountered while reading or writing `lightconfig.toml`, or while
456    /// resolving the fallback output directory.
457    pub fn get(
458        mut light_args: LightArgs,
459        openmw_config: &openmw_config::OpenMWConfiguration,
460    ) -> Result<LightConfig, io::Error> {
461        let user_config_path = openmw_config.user_config_path();
462
463        let early_no_notifications =
464            std::env::var("S3L_NO_NOTIFICATIONS").is_ok() || light_args.no_notifications;
465        let (mut light_config, write_config) =
466            Self::load(&user_config_path, early_no_notifications)?;
467
468        let debug_from_env = std::env::var("S3L_DEBUG").is_ok();
469
470        let (effective_dry_run, effective_validate_config) =
471            light_config.effective_non_writing_modes(&light_args)?;
472        let allow_config_writes = !effective_dry_run && !effective_validate_config;
473
474        light_config.apply_scalar_args(&mut light_args);
475        light_config.apply_bool_args(&light_args);
476        let persisted_no_notifications = light_config.no_notifications;
477        let persisted_debug = light_config.debug;
478        light_config.no_notifications |= std::env::var("S3L_NO_NOTIFICATIONS").is_ok();
479        light_config.debug |= debug_from_env;
480
481        if !effective_validate_config {
482            light_config.configure_output_dir(light_args.output.take(), openmw_config)?;
483        }
484        light_config.apply_collection_args(&mut light_args);
485
486        // This parameter indicates whether the user requested
487        // To use compatibility mode for vtastek's old 0.47 shaders
488        // via startup arguments
489        // Drastically increases light radii
490        // and disables interior sunlight
491        if light_args.use_classic {
492            light_config.disable_interior_sun = true;
493        }
494
495        if allow_config_writes
496            && (write_config || light_config.save_config || light_args.update_light_config)
497        {
498            light_config.save_to_user_config_without_runtime_flags(
499                &user_config_path,
500                persisted_no_notifications,
501                persisted_debug,
502            )?;
503        }
504
505        // Consume the original values *after* reserializing the config
506        light_config.compile_regexes()?;
507
508        Ok(light_config)
509    }
510
511    #[must_use]
512    pub fn is_excluded_plugin(&self, plugin_path: &std::path::Path) -> bool {
513        let file_name = match plugin_path.file_name() {
514            None => return false,
515            Some(name) => name.to_ascii_lowercase().into_string().unwrap_or_default(),
516        };
517
518        for pattern in &self.excluded_plugin_regexes {
519            if pattern.is_match(&file_name) {
520                return true;
521            }
522        }
523
524        false
525    }
526
527    #[must_use]
528    pub fn is_excluded_id(&self, record_id: &str) -> bool {
529        for pattern in &self.excluded_id_regexes {
530            if pattern.is_match(record_id) {
531                return true;
532            }
533        }
534
535        false
536    }
537}
538
539impl Default for LightConfig {
540    fn default() -> LightConfig {
541        LightConfig {
542            save_config: false,
543            debug: false,
544            dry_run: false,
545            validate_config: false,
546            no_notifications: false,
547            output_dir: None,
548            disable_interior_sun: false,
549            disable_flickering: default::disable_flicker(),
550            disable_pulse: default::disable_pulse(),
551            disable_negative_lights: default::disable_negative_lights(),
552            auto_enable: default::auto_enable(),
553            standard_hue: default::standard_hue(),
554            standard_saturation: default::standard_saturation(),
555            standard_value: default::standard_value(),
556            standard_radius: default::standard_radius(),
557            colored_hue: default::colored_hue(),
558            colored_saturation: default::colored_saturation(),
559            colored_value: default::colored_value(),
560            colored_radius: default::colored_radius(),
561            duration_mult: default::duration_mult(),
562            excluded_ids: Vec::new(),
563            excluded_plugins: default::excluded_plugins(),
564            excluded_id_regexes: Vec::new(),
565            excluded_plugin_regexes: Vec::new(),
566            light_regexes: Vec::new(),
567            light_overrides: OrderedHashMap::new(),
568            ambient_overrides: OrderedHashMap::new(),
569            ambient_regexes: Vec::new(),
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use std::path::{Path, PathBuf};
577
578    use clap::Parser;
579
580    use super::*;
581
582    struct TempDir {
583        path: PathBuf,
584    }
585
586    impl TempDir {
587        fn new(name: &str) -> Self {
588            let path = std::env::temp_dir()
589                .join(format!("s3lightfixes-config-{name}-{}", std::process::id()));
590            let _ = std::fs::remove_dir_all(&path);
591            std::fs::create_dir(&path).unwrap();
592
593            Self { path }
594        }
595
596        fn path(&self) -> &Path {
597            &self.path
598        }
599    }
600
601    impl Drop for TempDir {
602        fn drop(&mut self) {
603            let _ = std::fs::remove_dir_all(&self.path);
604        }
605    }
606
607    #[test]
608    fn toml_light_overrides_preserve_declaration_order() {
609        let config = toml::from_str::<LightConfig>(
610            r"
611            [light_overrides.first]
612            radius = 1
613
614            [light_overrides.second]
615            radius = 2
616
617            [light_overrides.third]
618            radius = 3
619            ",
620        )
621        .unwrap();
622
623        assert_eq!(
624            config.light_overrides.keys().collect::<Vec<_>>(),
625            vec!["first", "second", "third"]
626        );
627    }
628
629    #[test]
630    fn disable_negative_lights_defaults_true_and_can_be_loaded_false() {
631        let defaulted = toml::from_str::<LightConfig>("").unwrap();
632        assert!(defaulted.disable_negative_lights);
633
634        let configured = toml::from_str::<LightConfig>("disable_negative_lights = false").unwrap();
635        assert!(!configured.disable_negative_lights);
636    }
637
638    #[test]
639    fn cli_disable_negative_lights_overrides_config_without_short_form() {
640        let mut config = LightConfig {
641            disable_negative_lights: true,
642            ..LightConfig::default()
643        };
644        let args = LightArgs::parse_from([
645            "s3lightfixes",
646            "--disable-negative-lights",
647            "false",
648            "--no-flicker",
649            "false",
650        ]);
651
652        config.apply_bool_args(&args);
653
654        assert!(!config.disable_negative_lights);
655        assert!(!config.disable_flickering);
656    }
657
658    #[test]
659    fn non_writing_modes_are_toml_configurable_and_cli_overridable() {
660        let mut config = toml::from_str::<LightConfig>(
661            r"
662            dry_run = true
663            validate_config = false
664            ",
665        )
666        .unwrap();
667        let args = LightArgs::parse_from(["s3lightfixes", "--validate-config"]);
668
669        config.apply_bool_args(&args);
670
671        assert!(!config.dry_run);
672        assert!(config.validate_config);
673    }
674
675    #[test]
676    fn cli_false_can_disable_configured_non_writing_mode() {
677        let mut config = LightConfig {
678            dry_run: true,
679            ..LightConfig::default()
680        };
681        let args = LightArgs::parse_from(["s3lightfixes", "--dry-run", "false"]);
682
683        config.apply_bool_args(&args);
684
685        assert!(!config.dry_run);
686    }
687
688    #[test]
689    fn configured_dry_run_and_validate_config_conflict_without_cli_override() {
690        let config = LightConfig {
691            dry_run: true,
692            validate_config: true,
693            ..LightConfig::default()
694        };
695        let args = LightArgs::parse_from(["s3lightfixes"]);
696
697        let err = config.effective_non_writing_modes(&args).unwrap_err();
698
699        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
700    }
701
702    #[test]
703    fn invalid_regexes_are_validation_errors_instead_of_silent_drops() {
704        let mut config = LightConfig {
705            excluded_ids: vec!["[".to_owned()],
706            no_notifications: true,
707            ..LightConfig::default()
708        };
709
710        let err = config.compile_regexes().unwrap_err();
711
712        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
713        assert!(
714            err.to_string()
715                .contains("Couldn't compile excluded id regex")
716        );
717    }
718
719    #[test]
720    fn legacy_hsv_light_colors_are_preserved() {
721        let config = toml::from_str::<LightConfig>(
722            r"
723            [light_overrides.torch]
724            hue = 180
725            saturation = 1.0
726            value = 1.0
727            ",
728        )
729        .unwrap();
730
731        let serialized = toml::to_string(&config).unwrap();
732        assert!(serialized.contains("hue = 180"));
733        assert!(serialized.contains("saturation = 1.0"));
734        assert!(serialized.contains("value = 1.0"));
735        assert!(!serialized.contains("red ="));
736    }
737
738    #[test]
739    fn final_save_strips_env_only_notification_and_debug_state() {
740        let temp_dir = TempDir::new("final-save-strips-runtime-state");
741        let mut config = LightConfig {
742            no_notifications: true,
743            debug: true,
744            ..LightConfig::default()
745        };
746
747        config
748            .save_to_user_config_without_runtime_flags(temp_dir.path(), false, false)
749            .unwrap();
750
751        assert!(config.no_notifications);
752        assert!(config.debug);
753
754        let saved = std::fs::read_to_string(temp_dir.path().join(DEFAULT_CONFIG_NAME)).unwrap();
755        assert!(!saved.contains("no_notifications = true"));
756        assert!(!saved.contains("debug = true"));
757    }
758}