1use std::{
2 collections::HashSet,
3 env::var,
4 fs::{File, copy, metadata, remove_file},
5 io::{self, Write},
6 mem::take,
7 path::{Path, PathBuf},
8 process::exit,
9};
10
11use clap::{CommandFactory, Parser};
12use rayon::prelude::*;
13use tes3::esp::{
14 AtmosphereData, Cell, CellFlags, EditorId, FixedString, Header, Light, ObjectFlags, Plugin,
15 TES3Object, types::FileType,
16};
17use vfstool_lib::VFS;
18
19use crate::{
20 LOG_NAME, LightArgs, LightConfig, PLUGIN_NAME, is_fixable_plugin, notification_box, save_plugin,
21};
22
23use crate::light_processing::process_light;
24
25type LoadedPlugin<'a> = (Plugin, &'a Path);
26
27struct GenerationResult {
28 plugin: Plugin,
29 header: Header,
30 logs: Vec<RecordLog>,
31}
32
33#[derive(Debug, PartialEq, Eq)]
34struct RecordLog {
35 kind: &'static str,
36 plugin: String,
37 id: String,
38 changes: Vec<String>,
39}
40
41struct RunMetadata {
42 version: &'static str,
43 config_path: PathBuf,
44 output_path: PathBuf,
45 content_files: usize,
46 loaded_plugins: usize,
47 masters: usize,
48 changed_cells: usize,
49 changed_lights: usize,
50}
51
52impl RunMetadata {
53 fn new(
54 selected_config_file: &Path,
55 output_dir: &Path,
56 content_files: usize,
57 loaded_plugins: usize,
58 header: &Header,
59 logs: &[RecordLog],
60 ) -> Self {
61 Self {
62 version: env!("CARGO_PKG_VERSION"),
63 config_path: selected_config_file.to_owned(),
64 output_path: output_dir.join(PLUGIN_NAME),
65 content_files,
66 loaded_plugins,
67 masters: header.masters.len(),
68 changed_cells: logs.iter().filter(|log| log.kind == "CELL").count(),
69 changed_lights: logs.iter().filter(|log| log.kind == "LIGH").count(),
70 }
71 }
72}
73
74fn selected_config_file_path(config: &openmw_config::OpenMWConfiguration) -> PathBuf {
75 config.user_config_path().join("openmw.cfg")
76}
77
78fn plugin_log_name(plugin_path: &Path) -> String {
79 plugin_path.file_name().map_or_else(
80 || plugin_path.display().to_string(),
81 |name| name.to_string_lossy().to_string(),
82 )
83}
84
85fn explicit_config_path(args: &LightArgs) -> Option<PathBuf> {
86 let path = args.openmw_cfg.as_ref()?;
87 let absolute_path = if path.is_relative() {
88 path.canonicalize().unwrap_or_else(|_| path.to_owned())
89 } else {
90 path.to_owned()
91 };
92
93 if absolute_path.is_file()
94 || (absolute_path.is_dir() && absolute_path.join("openmw.cfg").is_file())
95 {
96 Some(absolute_path)
97 } else {
98 panic!("Explicit --openmw-cfg must be an openmw.cfg file or a directory containing one");
99 }
100}
101
102fn load_openmw_config(
103 args: &LightArgs,
104 no_notifications: bool,
105) -> openmw_config::OpenMWConfiguration {
106 let loaded_config = if let Some(config_path) = explicit_config_path(args) {
107 openmw_config::OpenMWConfiguration::new(Some(config_path))
108 } else {
109 openmw_config::OpenMWConfiguration::from_env()
110 };
111
112 match loaded_config {
113 Ok(config) => config,
114 Err(error) => {
115 notification_box(
116 "Failed to read configuration file!",
117 &error.to_string(),
118 no_notifications,
119 );
120
121 exit(127);
122 }
123 }
124}
125
126fn content_files_or_exit(
127 config: &openmw_config::OpenMWConfiguration,
128 no_notifications: bool,
129) -> Vec<String> {
130 let content_files = config
131 .content_files_iter()
132 .map(|plugin| plugin.value_str().to_owned())
133 .collect::<Vec<_>>();
134
135 if content_files.is_empty() {
136 notification_box(
137 "No Plugins!",
138 "No plugins were found in openmw.cfg! No lights to fix!",
139 no_notifications,
140 );
141 exit(4);
142 }
143
144 content_files
145}
146
147fn load_plugins<'a>(
148 content_files: &[String],
149 light_config: &LightConfig,
150 vfs: &'a VFS,
151) -> Vec<LoadedPlugin<'a>> {
152 content_files
153 .par_iter()
154 .rev()
155 .filter_map(|plugin| {
156 let vfs_file = vfs.get_file(plugin.as_str())?;
157 let path = vfs_file.path();
158
159 if !is_fixable_plugin(path) || light_config.is_excluded_plugin(path) {
160 return None;
161 }
162
163 match Plugin::from_path_filtered(path, |tag| matches!(&tag, Cell::TAG | Light::TAG)) {
164 Ok(plugin) => Some((plugin, path)),
165 Err(err) => {
166 eprintln!(
167 "[ WARNING ]: Plugin {}: could not be loaded due to error: {}. Continuing light fixes without this mod . . . Everything will be okay. Yes, it's still working.\n",
168 path.display(),
169 err
170 );
171 None
172 }
173 }
174 })
175 .collect::<Vec<_>>()
176}
177
178fn apply_cell_ambient_overrides(
179 light_config: &LightConfig,
180 cell_id: &str,
181 atmo: &mut AtmosphereData,
182) -> bool {
183 let mut replaced = false;
184
185 for (pattern, replacement_data) in &light_config.ambient_regexes {
186 if !pattern.is_match(cell_id) {
187 continue;
188 }
189
190 if let Some(ambient) = &replacement_data.ambient {
191 atmo.ambient_color = ambient.to_esp_color();
192 replaced = true;
193 }
194
195 if let Some(fog) = &replacement_data.fog {
196 atmo.fog_color = fog.to_esp_color();
197 replaced = true;
198 }
199
200 if let Some(sunlight) = &replacement_data.sunlight {
201 atmo.sunlight_color = sunlight.to_esp_color();
202 replaced = true;
203 }
204
205 if let Some(density) = replacement_data.fog_density {
206 atmo.fog_density = density;
207 replaced = true;
208 }
209 }
210
211 replaced
212}
213
214fn cell_changes(original: &AtmosphereData, modified: &AtmosphereData) -> Vec<String> {
215 let mut changes = Vec::new();
216
217 if original.ambient_color != modified.ambient_color {
218 changes.push(format!(
219 "ambient {:?} -> {:?}",
220 original.ambient_color, modified.ambient_color
221 ));
222 }
223
224 if original.sunlight_color != modified.sunlight_color {
225 changes.push(format!(
226 "sunlight {:?} -> {:?}",
227 original.sunlight_color, modified.sunlight_color
228 ));
229 }
230
231 if original.fog_color != modified.fog_color {
232 changes.push(format!(
233 "fog {:?} -> {:?}",
234 original.fog_color, modified.fog_color
235 ));
236 }
237
238 if (original.fog_density - modified.fog_density).abs() > f32::EPSILON {
239 changes.push(format!(
240 "fog_density {} -> {}",
241 original.fog_density, modified.fog_density
242 ));
243 }
244
245 changes
246}
247
248fn process_cells(
249 plugin: &mut Plugin,
250 plugin_name: &str,
251 generated_plugin: &mut Plugin,
252 light_config: &LightConfig,
253 used_ids: &mut HashSet<String>,
254 logs: &mut Vec<RecordLog>,
255) -> u32 {
256 let mut used_objects = 0;
257
258 for cell in plugin.objects_of_type_mut::<Cell>().filter(|cell| {
259 cell.data.flags.contains(CellFlags::IS_INTERIOR) && cell.atmosphere_data.is_some()
260 }) {
261 let cell_id = cell.editor_id_ascii_lowercase().into_owned();
262
263 if used_ids.contains(&cell_id) || light_config.is_excluded_id(&cell_id) {
264 continue;
265 }
266
267 let original_atmo = cell.atmosphere_data.clone();
268 if let Some(ref mut atmo) = cell.atmosphere_data {
269 cell.references.clear();
272
273 if cell.water_height.is_some() {
274 cell.water_height = None;
275 }
276
277 let mut replaced = false;
278
279 if light_config.disable_interior_sun {
280 atmo.sunlight_color = [0, 0, 0, 0];
281 replaced = true;
282 }
283
284 replaced |= apply_cell_ambient_overrides(light_config, &cell_id, atmo);
285
286 if replaced {
287 if let Some(original_atmo) = &original_atmo {
288 let changes = cell_changes(original_atmo, atmo);
289
290 if !changes.is_empty() {
291 logs.push(RecordLog {
292 kind: "CELL",
293 plugin: plugin_name.to_owned(),
294 id: cell_id.clone(),
295 changes,
296 });
297 }
298 }
299
300 generated_plugin.objects.push(take(cell).into());
301 used_ids.insert(cell_id);
302 used_objects += 1;
303 }
304 }
305 }
306
307 used_objects
308}
309
310fn process_lights(
311 plugin: Plugin,
312 plugin_name: &str,
313 generated_plugin: &mut Plugin,
314 light_config: &LightConfig,
315 used_ids: &mut HashSet<String>,
316 logs: &mut Vec<RecordLog>,
317) -> u32 {
318 let mut used_objects = 0;
319
320 plugin
321 .into_objects_of_type::<Light>()
322 .filter_map(|light| {
323 let light_id = light.editor_id_ascii_lowercase().into_owned();
324
325 if !used_ids.contains(&light_id) && !light_config.is_excluded_id(&light_id) {
326 used_ids.insert(light_id);
327 Some(light)
328 } else {
329 None
330 }
331 })
332 .for_each(|mut light| {
333 let changes = process_light(light_config, &mut light);
334
335 if !changes.is_empty() {
336 logs.push(RecordLog {
337 kind: "LIGH",
338 plugin: plugin_name.to_owned(),
339 id: light.id.clone(),
340 changes,
341 });
342 }
343
344 generated_plugin.objects.push(light.into());
345 used_objects += 1;
346 });
347
348 used_objects
349}
350
351fn header_for_generated_plugin() -> Header {
352 Header {
353 version: 1.3,
354 author: FixedString("S3".to_string()),
355 description: FixedString("Plugin generated by s3-lightfixes".to_string()),
356 file_type: FileType::Esp,
357 flags: ObjectFlags::default(),
358 num_objects: 0,
359 masters: Vec::new(),
360 }
361}
362
363fn plugin_master(plugin_path: &Path, no_notifications: bool) -> io::Result<(String, u64)> {
364 let plugin_size = metadata(plugin_path)?.len();
365 let Some(name) = plugin_path.file_name() else {
366 notification_box(
367 "Bad plugin path!",
368 "Lightfixes could not resolve the name of one of your plugins! This is UBER Bad and should never happen!",
369 no_notifications,
370 );
371 exit(3);
372 };
373
374 Ok((name.to_string_lossy().to_string(), plugin_size))
375}
376
377fn generate_plugin(
378 plugins: Vec<LoadedPlugin<'_>>,
379 light_config: &LightConfig,
380) -> io::Result<GenerationResult> {
381 let mut plugin = Plugin::new();
382 let mut header = header_for_generated_plugin();
383 let mut used_ids = HashSet::new();
384 let mut logs = Vec::new();
385
386 for (mut source_plugin, plugin_path) in plugins {
387 let plugin_name = plugin_log_name(plugin_path);
388 let used_cell_objects = process_cells(
389 &mut source_plugin,
390 &plugin_name,
391 &mut plugin,
392 light_config,
393 &mut used_ids,
394 &mut logs,
395 );
396 let used_light_objects = process_lights(
397 source_plugin,
398 &plugin_name,
399 &mut plugin,
400 light_config,
401 &mut used_ids,
402 &mut logs,
403 );
404 let used_objects = used_cell_objects + used_light_objects;
405
406 if used_objects > 0 {
407 let (plugin_string, plugin_size) =
408 plugin_master(plugin_path, light_config.no_notifications)?;
409
410 header.masters.insert(0, (plugin_string, plugin_size));
411 header.num_objects += used_objects;
412 }
413 }
414
415 Ok(GenerationResult {
416 plugin,
417 header,
418 logs,
419 })
420}
421
422fn remove_old_plugin_from_data_local(config: &mut openmw_config::OpenMWConfiguration) {
423 if let Some(dir) = &mut config.data_local() {
424 let old_plug_path = dir.parsed().join(PLUGIN_NAME);
425 if old_plug_path.is_file() {
426 let _ = remove_file(old_plug_path);
427 }
428 }
429}
430
431fn backup_openmw_cfg(selected_config_file: &Path) -> io::Result<PathBuf> {
432 let file_name = selected_config_file.file_name().ok_or_else(|| {
433 io::Error::new(
434 io::ErrorKind::InvalidInput,
435 "selected OpenMW config path has no file name",
436 )
437 })?;
438 let backup_name = format!("{}.s3lightfixes.bak", file_name.to_string_lossy());
439 let backup_path = selected_config_file.with_file_name(backup_name);
440
441 copy(selected_config_file, &backup_path)?;
442
443 Ok(backup_path)
444}
445
446fn auto_enable_plugin(
447 config: &mut openmw_config::OpenMWConfiguration,
448 light_config: &LightConfig,
449 selected_config_file: &Path,
450) -> bool {
451 if !light_config.auto_enable {
452 return false;
453 }
454
455 if config.has_content_file(PLUGIN_NAME) {
456 return true;
457 }
458
459 let backup_path = match backup_openmw_cfg(selected_config_file) {
460 Ok(path) => path,
461 Err(err) => {
462 notification_box(
463 "Failed to back up openmw.cfg!",
464 &format!(
465 "Refusing to auto-enable {PLUGIN_NAME} because openmw.cfg could not be backed up: {err}"
466 ),
467 light_config.no_notifications,
468 );
469 return false;
470 }
471 };
472
473 match config.add_content_file(PLUGIN_NAME) {
474 Ok(()) => {
475 if let Err(err) = config.save_user() {
476 notification_box(
477 "Failed to resave openmw.cfg!",
478 &err.to_string(),
479 light_config.no_notifications,
480 );
481 false
482 } else {
483 let lightfix_enabled_msg = format!(
484 "Wrote selected OpenMW config at {} successfully! Backup saved at {}.",
485 selected_config_file.display(),
486 backup_path.display()
487 );
488 notification_box(
489 "Lightfixes enabled!",
490 &lightfix_enabled_msg,
491 light_config.no_notifications,
492 );
493 true
494 }
495 }
496 Err(err) => {
497 eprintln!("{err}");
498 exit(256);
499 }
500 }
501}
502
503fn write_log_outputs(
504 config: &openmw_config::OpenMWConfiguration,
505 metadata: &RunMetadata,
506 logs: &[RecordLog],
507) -> io::Result<()> {
508 let stdout = io::stdout();
509 let mut stdout = stdout.lock();
510 match write_log_to(&mut stdout, metadata, logs) {
511 Ok(()) => {}
512 Err(err) if err.kind() == io::ErrorKind::BrokenPipe => {}
513 Err(err) => return Err(err),
514 }
515
516 let path = config.user_config_path().join(LOG_NAME);
517 let mut file = File::create(path)?;
518 write_log_to(&mut file, metadata, logs)
519}
520
521fn write_dry_run_outputs(metadata: &RunMetadata, logs: &[RecordLog]) -> io::Result<()> {
522 let stdout = io::stdout();
523 let mut stdout = stdout.lock();
524 write_dry_run_to(&mut stdout, metadata, logs)
525}
526
527fn write_dry_run_to(
528 mut writer: impl Write,
529 metadata: &RunMetadata,
530 logs: &[RecordLog],
531) -> io::Result<()> {
532 writeln!(writer, "Dry run: no files written")?;
533 write_log_to(&mut writer, metadata, logs)
534}
535
536fn write_log_to(
537 mut writer: impl Write,
538 metadata: &RunMetadata,
539 logs: &[RecordLog],
540) -> io::Result<()> {
541 writeln!(writer, "# S3LightFixes {}", metadata.version)?;
542 writeln!(writer, "# config: {}", metadata.config_path.display())?;
543 writeln!(writer, "# output: {}", metadata.output_path.display())?;
544 writeln!(writer, "# content files: {}", metadata.content_files)?;
545 writeln!(writer, "# loaded plugins: {}", metadata.loaded_plugins)?;
546 writeln!(writer, "# masters: {}", metadata.masters)?;
547 writeln!(writer, "# changed cells: {}", metadata.changed_cells)?;
548 writeln!(writer, "# changed lights: {}", metadata.changed_lights)?;
549
550 for log in logs {
551 writeln!(
552 writer,
553 "{} {:?} from {:?}: {}",
554 log.kind,
555 log.id,
556 log.plugin,
557 log.changes.join(", ")
558 )?;
559 }
560
561 Ok(())
562}
563
564fn handle_generated_output(args: &LightArgs, stdout: &mut dyn Write) -> io::Result<bool> {
565 if let Some(shell) = args.generate_completion {
566 let mut command = LightArgs::command();
567 clap_complete::generate(shell, &mut command, "s3lightfixes", stdout);
568 return Ok(true);
569 }
570
571 if args.generate_manpage {
572 clap_mangen::Man::new(LightArgs::command()).render(stdout)?;
573 return Ok(true);
574 }
575
576 Ok(false)
577}
578
579#[allow(clippy::too_many_lines)]
587pub fn run() -> io::Result<()> {
588 let args = LightArgs::parse();
589
590 if handle_generated_output(&args, &mut io::stdout())? {
591 return Ok(());
592 }
593
594 let no_notifications = var("S3L_NO_NOTIFICATIONS").is_ok() || args.no_notifications;
595 let mut config = load_openmw_config(&args, no_notifications);
596 let selected_config_file = selected_config_file_path(&config);
597 let light_config = LightConfig::get(args, &config)?;
598
599 if light_config.validate_config {
600 println!(
601 "Validated {} successfully",
602 config
603 .user_config_path()
604 .join(crate::DEFAULT_CONFIG_NAME)
605 .display()
606 );
607 return Ok(());
608 }
609
610 let output_dir = light_config.output_dir.clone().unwrap_or_else(|| {
611 notification_box(
612 "Can't get output directory!",
613 "[ CRITICAL FAILURE ]: FAILED TO RESOLVE OUTPUT DIRECTORY!",
614 light_config.no_notifications,
615 );
616 exit(256);
617 });
618
619 if light_config.debug {
620 dbg!(&light_config, &config);
621 }
622
623 let content_files = content_files_or_exit(&config, light_config.no_notifications);
624 let directories = config
625 .data_directories_iter()
626 .map(openmw_config::DirectorySetting::parsed)
627 .collect::<Vec<_>>();
628 let vfs = VFS::from_directories(directories, None);
629 let plugins = load_plugins(&content_files, &light_config, &vfs);
630 let loaded_plugins = plugins.len();
631 let GenerationResult {
632 mut plugin,
633 header,
634 logs,
635 } = generate_plugin(plugins, &light_config)?;
636
637 if light_config.debug {
638 dbg!(&header);
639 }
640
641 let metadata = RunMetadata::new(
642 &selected_config_file,
643 &output_dir,
644 content_files.len(),
645 loaded_plugins,
646 &header,
647 &logs,
648 );
649
650 if header.masters.is_empty() {
651 if light_config.dry_run {
652 write_dry_run_outputs(&metadata, &logs)?;
653 return Ok(());
654 }
655
656 notification_box(
657 "No masters found!",
658 "The generated plugin was not found to have any master files! It's empty! Try running lightfixes again using the S3L_DEBUG environment variable",
659 light_config.no_notifications,
660 );
661 exit(2);
662 }
663
664 if light_config.dry_run {
665 write_dry_run_outputs(&metadata, &logs)?;
666 return Ok(());
667 }
668
669 plugin.objects.push(TES3Object::Header(header));
670 plugin.sort_objects();
671
672 remove_old_plugin_from_data_local(&mut config);
675
676 save_plugin(&output_dir, &mut plugin).inspect_err(|err| {
677 notification_box(
678 "Failed to save plugin!",
679 &err.to_string(),
680 light_config.no_notifications,
681 );
682 })?;
683
684 let enabled = auto_enable_plugin(&mut config, &light_config, &selected_config_file);
685 write_log_outputs(&config, &metadata, &logs)?;
686
687 let lights_fixed = if enabled {
688 format!(
689 "S3LightFixes.omwaddon generated, enabled, and saved in {}",
690 output_dir.display()
691 )
692 } else {
693 format!(
694 "S3LightFixes.omwaddon generated and saved in {}",
695 output_dir.display()
696 )
697 };
698
699 notification_box(
700 "Lightfixes successful!",
701 &lights_fixed,
702 light_config.no_notifications,
703 );
704
705 Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710 use std::sync::atomic::{AtomicU64, Ordering};
711
712 use regex::Regex;
713 use tes3::esp::{AtmosphereData, CellData, LightData, LightFlags, Reference, TES3Object};
714
715 use super::*;
716 use crate::{CustomCellAmbient, light_override::TypedLightColor};
717
718 static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
719
720 struct TempPluginFile {
721 path: PathBuf,
722 }
723
724 impl TempPluginFile {
725 fn as_path(&self) -> &Path {
726 &self.path
727 }
728 }
729
730 impl Drop for TempPluginFile {
731 fn drop(&mut self) {
732 let _ = std::fs::remove_file(&self.path);
733 }
734 }
735
736 fn temp_plugin_file(name: &str, size: usize) -> TempPluginFile {
737 let path = std::env::temp_dir().join(format!(
738 "s3lightfixes-{name}-{}-{}",
739 std::process::id(),
740 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
741 ));
742 std::fs::write(&path, vec![0; size]).unwrap();
743
744 TempPluginFile { path }
745 }
746
747 fn light(id: &str, radius: u32) -> Light {
748 Light {
749 id: id.to_owned(),
750 data: LightData {
751 radius,
752 time: 10,
753 color: [255, 128, 0, 0],
754 flags: LightFlags::default(),
755 ..LightData::default()
756 },
757 ..Light::default()
758 }
759 }
760
761 fn plugin_with_lights(lights: impl IntoIterator<Item = Light>) -> Plugin {
762 Plugin {
763 objects: lights.into_iter().map(TES3Object::from).collect(),
764 }
765 }
766
767 fn generated_lights(plugin: &Plugin) -> Vec<&Light> {
768 plugin.objects_of_type::<Light>().collect()
769 }
770
771 fn generated_cells(plugin: &Plugin) -> Vec<&Cell> {
772 plugin.objects_of_type::<Cell>().collect()
773 }
774
775 fn config() -> LightConfig {
776 LightConfig {
777 standard_hue: 1.0,
778 standard_saturation: 1.0,
779 standard_value: 1.0,
780 standard_radius: 1.0,
781 colored_hue: 1.0,
782 colored_saturation: 1.0,
783 colored_value: 1.0,
784 colored_radius: 1.0,
785 duration_mult: 1.0,
786 ..LightConfig::default()
787 }
788 }
789
790 fn test_metadata(changed_cells: usize, changed_lights: usize) -> RunMetadata {
791 RunMetadata {
792 version: "test-version",
793 config_path: PathBuf::from("/tmp/openmw.cfg"),
794 output_path: PathBuf::from("/tmp/out/S3LightFixes.omwaddon"),
795 content_files: 3,
796 loaded_plugins: 2,
797 masters: 1,
798 changed_cells,
799 changed_lights,
800 }
801 }
802
803 #[test]
804 fn generated_completion_goes_to_stdout_without_running_lightfixes() {
805 let args = LightArgs::parse_from(["s3lightfixes", "--generate-completion", "bash"]);
806 let mut stdout = Vec::new();
807
808 assert!(handle_generated_output(&args, &mut stdout).unwrap());
809
810 let completion = String::from_utf8(stdout).unwrap();
811 assert!(completion.contains("_s3lightfixes"));
812 assert!(completion.contains("--generate-manpage"));
813 }
814
815 #[test]
816 fn generated_manpage_goes_to_stdout_without_running_lightfixes() {
817 let args = LightArgs::parse_from(["s3lightfixes", "--generate-manpage"]);
818 let mut stdout = Vec::new();
819
820 assert!(handle_generated_output(&args, &mut stdout).unwrap());
821
822 let manpage = String::from_utf8(stdout).unwrap();
823 assert!(manpage.contains("s3lightfixes"));
824 assert!(manpage.contains("A tool for modifying light values globally"));
825 }
826
827 #[test]
828 fn generated_outputs_conflict_with_each_other() {
829 let err = LightArgs::try_parse_from([
830 "s3lightfixes",
831 "--generate-completion",
832 "bash",
833 "--generate-manpage",
834 ])
835 .unwrap_err();
836
837 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
838 }
839
840 #[test]
841 fn dry_run_and_validate_config_conflict_with_each_other() {
842 let err = LightArgs::try_parse_from(["s3lightfixes", "--dry-run", "--validate-config"])
843 .unwrap_err();
844
845 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
846 }
847
848 #[test]
849 fn dry_run_output_reports_target_and_planned_record_changes() {
850 let mut stdout = Vec::new();
851 let logs = [RecordLog {
852 kind: "LIGH",
853 plugin: "source.esp".to_owned(),
854 id: "torch_01".to_owned(),
855 changes: vec!["radius 10 -> 20".to_owned()],
856 }];
857 let metadata = test_metadata(0, 1);
858
859 write_dry_run_to(&mut stdout, &metadata, &logs).unwrap();
860
861 let output = String::from_utf8(stdout).unwrap();
862 assert!(output.contains("Dry run: no files written"));
863 assert!(output.contains("# output: /tmp/out/S3LightFixes.omwaddon"));
864 assert!(output.contains("# changed lights: 1"));
865 assert!(output.contains("LIGH \"torch_01\" from \"source.esp\": radius 10 -> 20"));
866 }
867
868 #[test]
869 fn backup_openmw_cfg_copies_existing_user_config_before_auto_enable() {
870 let temp_dir = std::env::temp_dir().join(format!(
871 "s3lightfixes-openmw-backup-{}-{}",
872 std::process::id(),
873 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
874 ));
875 std::fs::create_dir(&temp_dir).unwrap();
876 let selected_config = temp_dir.join("custom-openmw.cfg");
877 std::fs::write(&selected_config, "content=Morrowind.esm\n").unwrap();
878
879 let backup_path = backup_openmw_cfg(&selected_config).unwrap();
880
881 assert_eq!(
882 backup_path,
883 temp_dir.join("custom-openmw.cfg.s3lightfixes.bak")
884 );
885 assert_eq!(
886 std::fs::read_to_string(backup_path).unwrap(),
887 "content=Morrowind.esm\n"
888 );
889
890 let _ = std::fs::remove_dir_all(temp_dir);
891 }
892
893 fn interior_cell(id: &str) -> Cell {
894 Cell {
895 name: id.to_owned(),
896 data: CellData {
897 flags: CellFlags::IS_INTERIOR,
898 ..CellData::default()
899 },
900 atmosphere_data: Some(AtmosphereData {
901 ambient_color: [1, 2, 3, 0],
902 sunlight_color: [4, 5, 6, 0],
903 fog_color: [7, 8, 9, 0],
904 fog_density: 0.25,
905 }),
906 water_height: Some(10.0),
907 references: [((0, 1), Reference::default())].into(),
908 ..Cell::default()
909 }
910 }
911
912 fn exterior_cell(id: &str) -> Cell {
913 Cell {
914 name: id.to_owned(),
915 atmosphere_data: Some(AtmosphereData::default()),
916 water_height: Some(10.0),
917 references: [((0, 1), Reference::default())].into(),
918 ..Cell::default()
919 }
920 }
921
922 #[test]
923 fn generate_plugin_uses_first_processed_duplicate_id_and_keeps_unique_lights() {
924 let later_path = temp_plugin_file("later.omwaddon", 11);
925 let earlier_path = temp_plugin_file("earlier.omwaddon", 13);
926 let light_config = config();
927 let plugins = vec![
928 (
929 plugin_with_lights([light("Shared_Light", 300), light("later_only", 301)]),
930 later_path.as_path(),
931 ),
932 (
933 plugin_with_lights([light("shared_light", 100), light("earlier_only", 101)]),
934 earlier_path.as_path(),
935 ),
936 ];
937
938 let result = generate_plugin(plugins, &light_config).unwrap();
939 let lights = generated_lights(&result.plugin);
940
941 assert_eq!(lights.len(), 3);
942 assert_eq!(
943 lights
944 .iter()
945 .find(|light| light.id.eq_ignore_ascii_case("shared_light"))
946 .unwrap()
947 .data
948 .radius,
949 300
950 );
951 assert!(lights.iter().any(|light| light.id == "later_only"));
952 assert!(lights.iter().any(|light| light.id == "earlier_only"));
953 assert_eq!(result.header.num_objects, 3);
954 }
955
956 #[test]
957 fn generate_plugin_adds_masters_only_for_plugins_that_contribute_objects() {
958 let contributing_a = temp_plugin_file("contributing_a.omwaddon", 11);
959 let duplicate_only = temp_plugin_file("duplicate_only.omwaddon", 13);
960 let contributing_c = temp_plugin_file("contributing_c.omwaddon", 17);
961 let light_config = config();
962 let plugins = vec![
963 (
964 plugin_with_lights([light("shared", 100)]),
965 contributing_a.as_path(),
966 ),
967 (
968 plugin_with_lights([light("shared", 200)]),
969 duplicate_only.as_path(),
970 ),
971 (
972 plugin_with_lights([light("unique", 300)]),
973 contributing_c.as_path(),
974 ),
975 ];
976
977 let result = generate_plugin(plugins, &light_config).unwrap();
978
979 assert_eq!(result.header.num_objects, 2);
980 assert_eq!(
981 result.header.masters,
982 vec![
983 (
984 contributing_c
985 .as_path()
986 .file_name()
987 .unwrap()
988 .to_string_lossy()
989 .to_string(),
990 17
991 ),
992 (
993 contributing_a
994 .as_path()
995 .file_name()
996 .unwrap()
997 .to_string_lossy()
998 .to_string(),
999 11
1000 ),
1001 ]
1002 );
1003 }
1004
1005 #[test]
1006 fn compatibility_fixture_preserves_core_generation_contracts() {
1007 let first_processed = temp_plugin_file("first_processed.esp", 11);
1008 let second_processed = temp_plugin_file("second_processed.esp", 13);
1009 let mut light_config = config();
1010 light_config
1011 .excluded_id_regexes
1012 .push(Regex::new("excluded").unwrap());
1013 let plugins = vec![
1014 (
1015 plugin_with_lights([
1016 light("duplicate_light", 10),
1017 Light {
1018 id: "negative_light".to_owned(),
1019 data: LightData {
1020 radius: 20,
1021 color: [255, 128, 0, 0],
1022 flags: LightFlags::NEGATIVE,
1023 ..LightData::default()
1024 },
1025 ..Light::default()
1026 },
1027 light("excluded_light", 30),
1028 ]),
1029 first_processed.as_path(),
1030 ),
1031 (
1032 plugin_with_lights([
1033 light("duplicate_light", 99),
1034 light("second_unique_light", 40),
1035 ]),
1036 second_processed.as_path(),
1037 ),
1038 ];
1039
1040 let mut result = generate_plugin(plugins, &light_config).unwrap();
1041 result
1042 .plugin
1043 .objects
1044 .push(TES3Object::Header(result.header));
1045 result.plugin.sort_objects();
1046
1047 let generated = generated_lights(&result.plugin);
1048 assert_eq!(generated.len(), 3);
1049 assert!(
1050 generated
1051 .iter()
1052 .any(|light| light.id == "duplicate_light" && light.data.radius == 10)
1053 );
1054 assert!(generated.iter().all(|light| light.id != "excluded_light"));
1055 assert!(
1056 generated
1057 .iter()
1058 .any(|light| light.id == "second_unique_light" && light.data.radius == 40)
1059 );
1060 let negative = generated
1061 .iter()
1062 .find(|light| light.id == "negative_light")
1063 .unwrap();
1064 assert_eq!(negative.data.radius, 0);
1065 assert!(!negative.data.flags.contains(LightFlags::NEGATIVE));
1066
1067 let TES3Object::Header(header) = &result.plugin.objects[0] else {
1068 panic!("generated plugin header was not sorted first");
1069 };
1070 assert_eq!(header.num_objects, 3);
1071 assert_eq!(header.masters.len(), 2);
1072 assert_eq!(result.logs.len(), 1);
1073 assert_eq!(result.logs[0].id, "negative_light");
1074 }
1075
1076 #[test]
1077 fn process_lights_skips_excluded_ids_that_would_otherwise_emit() {
1078 let mut light_config = config();
1079 light_config
1080 .excluded_id_regexes
1081 .push(Regex::new("excluded_light").unwrap());
1082 let source_plugin = plugin_with_lights([light("excluded_light", 100), light("kept", 200)]);
1083 let mut generated_plugin = Plugin::new();
1084 let mut used_ids = HashSet::new();
1085 let mut logs = Vec::new();
1086
1087 let used_objects = process_lights(
1088 source_plugin,
1089 "TestPlugin.esp",
1090 &mut generated_plugin,
1091 &light_config,
1092 &mut used_ids,
1093 &mut logs,
1094 );
1095
1096 let lights = generated_lights(&generated_plugin);
1097 assert_eq!(used_objects, 1);
1098 assert_eq!(lights.len(), 1);
1099 assert_eq!(lights[0].id, "kept");
1100 assert!(!used_ids.contains("excluded_light"));
1101 assert!(used_ids.contains("kept"));
1102 assert!(logs.is_empty());
1103 }
1104
1105 #[test]
1106 fn process_lights_logs_actual_deltas_for_modified_lights() {
1107 let mut light_config = config();
1108 light_config.standard_radius = 2.0;
1109 let source_plugin = plugin_with_lights([light("modified_light", 100)]);
1110 let mut generated_plugin = Plugin::new();
1111 let mut used_ids = HashSet::new();
1112 let mut logs = Vec::new();
1113
1114 let used_objects = process_lights(
1115 source_plugin,
1116 "ModifiedPlugin.esp",
1117 &mut generated_plugin,
1118 &light_config,
1119 &mut used_ids,
1120 &mut logs,
1121 );
1122
1123 assert_eq!(used_objects, 1);
1124 assert_eq!(logs.len(), 1);
1125 assert_eq!(logs[0].kind, "LIGH");
1126 assert_eq!(logs[0].plugin, "ModifiedPlugin.esp");
1127 assert_eq!(logs[0].id, "modified_light");
1128 assert!(logs[0].changes.contains(&"radius 100 -> 200".to_owned()));
1129 }
1130
1131 #[test]
1132 fn ambient_cell_replacement_consumes_id_before_light_processing() {
1133 let mut light_config = config();
1134 light_config.disable_interior_sun = true;
1135 let path = temp_plugin_file("shared_cell_light.omwaddon", 19);
1136 let plugin = Plugin {
1137 objects: vec![
1138 interior_cell("shared_id").into(),
1139 light("shared_id", 100).into(),
1140 ],
1141 };
1142
1143 let result = generate_plugin(vec![(plugin, path.as_path())], &light_config).unwrap();
1144
1145 assert_eq!(generated_cells(&result.plugin).len(), 1);
1146 assert!(generated_lights(&result.plugin).is_empty());
1147 assert_eq!(result.header.num_objects, 1);
1148 }
1149
1150 #[test]
1151 fn process_cells_emits_ambient_replacement_and_strips_instance_state() {
1152 let mut light_config = config();
1153 light_config.ambient_regexes.push((
1154 Regex::new("ambient_cell").unwrap(),
1155 CustomCellAmbient {
1156 ambient: Some(TypedLightColor {
1157 red: 0,
1158 green: 255,
1159 blue: 255,
1160 }),
1161 sunlight: Some(TypedLightColor {
1162 red: 0,
1163 green: 0,
1164 blue: 255,
1165 }),
1166 fog: Some(TypedLightColor {
1167 red: 0,
1168 green: 255,
1169 blue: 0,
1170 }),
1171 fog_density: Some(0.75),
1172 },
1173 ));
1174 let mut source_plugin = Plugin {
1175 objects: vec![interior_cell("ambient_cell").into()],
1176 };
1177 let mut generated_plugin = Plugin::new();
1178 let mut used_ids = HashSet::new();
1179 let mut logs = Vec::new();
1180
1181 let used_objects = process_cells(
1182 &mut source_plugin,
1183 "AmbientPlugin.esp",
1184 &mut generated_plugin,
1185 &light_config,
1186 &mut used_ids,
1187 &mut logs,
1188 );
1189
1190 let cells = generated_cells(&generated_plugin);
1191 assert_eq!(used_objects, 1);
1192 assert_eq!(cells.len(), 1);
1193 assert!(used_ids.contains("ambient_cell"));
1194 assert!(cells[0].references.is_empty());
1195 assert!(cells[0].water_height.is_none());
1196
1197 let atmo = cells[0].atmosphere_data.as_ref().unwrap();
1198 assert_eq!(atmo.ambient_color, [0, 255, 255, 0]);
1199 assert_eq!(atmo.sunlight_color, [0, 0, 255, 0]);
1200 assert_eq!(atmo.fog_color, [0, 255, 0, 0]);
1201 assert!((atmo.fog_density - 0.75).abs() < f32::EPSILON);
1202 assert_eq!(logs.len(), 1);
1203 assert_eq!(logs[0].kind, "CELL");
1204 assert_eq!(logs[0].plugin, "AmbientPlugin.esp");
1205 assert_eq!(logs[0].id, "ambient_cell");
1206 assert!(
1207 logs[0]
1208 .changes
1209 .contains(&"ambient [1, 2, 3, 0] -> [0, 255, 255, 0]".to_owned())
1210 );
1211 assert!(
1212 logs[0]
1213 .changes
1214 .contains(&"sunlight [4, 5, 6, 0] -> [0, 0, 255, 0]".to_owned())
1215 );
1216 }
1217
1218 #[test]
1219 fn process_cells_disable_interior_sun_counts_as_replacement() {
1220 let mut light_config = config();
1221 light_config.disable_interior_sun = true;
1222 let mut source_plugin = Plugin {
1223 objects: vec![interior_cell("sun_cell").into()],
1224 };
1225 let mut generated_plugin = Plugin::new();
1226 let mut used_ids = HashSet::new();
1227 let mut logs = Vec::new();
1228
1229 let used_objects = process_cells(
1230 &mut source_plugin,
1231 "SunPlugin.esp",
1232 &mut generated_plugin,
1233 &light_config,
1234 &mut used_ids,
1235 &mut logs,
1236 );
1237
1238 let cells = generated_cells(&generated_plugin);
1239 assert_eq!(used_objects, 1);
1240 assert_eq!(cells.len(), 1);
1241 assert_eq!(
1242 cells[0].atmosphere_data.as_ref().unwrap().sunlight_color,
1243 [0, 0, 0, 0]
1244 );
1245 assert!(cells[0].references.is_empty());
1246 assert!(cells[0].water_height.is_none());
1247 assert!(used_ids.contains("sun_cell"));
1248 assert_eq!(logs.len(), 1);
1249 assert!(
1250 logs[0]
1251 .changes
1252 .contains(&"sunlight [4, 5, 6, 0] -> [0, 0, 0, 0]".to_owned())
1253 );
1254 }
1255
1256 #[test]
1257 fn process_cells_does_not_log_stripped_patch_only_state() {
1258 let mut light_config = config();
1259 light_config.disable_interior_sun = true;
1260 let mut source_plugin = Plugin {
1261 objects: vec![
1262 Cell {
1263 name: "already_dark_cell".to_owned(),
1264 data: CellData {
1265 flags: CellFlags::IS_INTERIOR,
1266 ..CellData::default()
1267 },
1268 atmosphere_data: Some(AtmosphereData {
1269 sunlight_color: [0, 0, 0, 0],
1270 ..AtmosphereData::default()
1271 }),
1272 references: [((0, 1), Reference::default())].into(),
1273 water_height: Some(42.0),
1274 ..Cell::default()
1275 }
1276 .into(),
1277 ],
1278 };
1279 let mut generated_plugin = Plugin::new();
1280 let mut used_ids = HashSet::new();
1281 let mut logs = Vec::new();
1282
1283 let used_objects = process_cells(
1284 &mut source_plugin,
1285 "AlreadyDark.esp",
1286 &mut generated_plugin,
1287 &light_config,
1288 &mut used_ids,
1289 &mut logs,
1290 );
1291
1292 assert_eq!(used_objects, 1);
1293 assert!(logs.is_empty());
1294 assert!(generated_cells(&generated_plugin)[0].references.is_empty());
1295 assert!(generated_cells(&generated_plugin)[0].water_height.is_none());
1296 }
1297
1298 #[test]
1299 fn process_cells_leaves_skipped_cells_out_of_generated_plugin() {
1300 let mut light_config = config();
1301 light_config.disable_interior_sun = true;
1302 light_config
1303 .excluded_id_regexes
1304 .push(Regex::new("excluded_cell").unwrap());
1305 let mut used_ids = HashSet::from(["duplicate_cell".to_owned()]);
1306 let mut source_plugin = Plugin {
1307 objects: vec![
1308 exterior_cell("exterior_cell").into(),
1309 Cell {
1310 name: "no_atmosphere".to_owned(),
1311 data: CellData {
1312 flags: CellFlags::IS_INTERIOR,
1313 ..CellData::default()
1314 },
1315 atmosphere_data: None,
1316 ..Cell::default()
1317 }
1318 .into(),
1319 interior_cell("excluded_cell").into(),
1320 interior_cell("duplicate_cell").into(),
1321 ],
1322 };
1323 let mut generated_plugin = Plugin::new();
1324 let mut logs = Vec::new();
1325
1326 let used_objects = process_cells(
1327 &mut source_plugin,
1328 "SkippedPlugin.esp",
1329 &mut generated_plugin,
1330 &light_config,
1331 &mut used_ids,
1332 &mut logs,
1333 );
1334
1335 assert_eq!(used_objects, 0);
1336 assert!(generated_cells(&generated_plugin).is_empty());
1337 assert!(used_ids.contains("duplicate_cell"));
1338 assert!(!used_ids.contains("excluded_cell"));
1339 assert!(logs.is_empty());
1340 }
1341
1342 #[test]
1343 fn write_log_reports_writer_errors() {
1344 struct BrokenWriter {
1345 attempted_write: bool,
1346 }
1347
1348 impl Write for BrokenWriter {
1349 fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
1350 self.attempted_write = true;
1351 Err(io::Error::other("broken writer"))
1352 }
1353
1354 fn flush(&mut self) -> io::Result<()> {
1355 Ok(())
1356 }
1357 }
1358
1359 let mut writer = BrokenWriter {
1360 attempted_write: false,
1361 };
1362 let logs = [RecordLog {
1363 kind: "LIGH",
1364 plugin: "BrokenPlugin.esp".to_owned(),
1365 id: "broken_writer".to_owned(),
1366 changes: vec!["radius 1 -> 2".to_owned()],
1367 }];
1368
1369 let metadata = test_metadata(0, 1);
1370
1371 let err = write_log_to(&mut writer, &metadata, &logs).unwrap_err();
1372
1373 assert!(writer.attempted_write);
1374 assert_eq!(err.kind(), io::ErrorKind::Other);
1375 }
1376
1377 #[test]
1378 fn write_log_emits_one_line_per_modified_record() {
1379 let logs = [
1380 RecordLog {
1381 kind: "CELL",
1382 plugin: "Morrowind.esm".to_owned(),
1383 id: "cell_id".to_owned(),
1384 changes: vec![
1385 "sunlight [1, 2, 3, 0] -> [0, 0, 0, 0]".to_owned(),
1386 "fog_density 0.5 -> 0.75".to_owned(),
1387 ],
1388 },
1389 RecordLog {
1390 kind: "LIGH",
1391 plugin: "Tribunal.esm".to_owned(),
1392 id: "light_id".to_owned(),
1393 changes: vec![
1394 "color [1, 2, 3, 0] -> [4, 5, 6, 0]".to_owned(),
1395 "radius 128 -> 256".to_owned(),
1396 ],
1397 },
1398 ];
1399 let mut output = Vec::new();
1400 let metadata = test_metadata(1, 1);
1401
1402 write_log_to(&mut output, &metadata, &logs).unwrap();
1403
1404 let output = String::from_utf8(output).unwrap();
1405 assert!(output.contains("# S3LightFixes test-version"));
1406 assert!(output.contains("# content files: 3"));
1407 assert!(output.contains("# loaded plugins: 2"));
1408 assert!(output.contains("# changed cells: 1"));
1409 assert!(output.contains("# changed lights: 1"));
1410 assert!(output.contains("CELL \"cell_id\" from \"Morrowind.esm\": sunlight [1, 2, 3, 0] -> [0, 0, 0, 0], fog_density 0.5 -> 0.75"));
1411 assert!(output.contains("LIGH \"light_id\" from \"Tribunal.esm\": color [1, 2, 3, 0] -> [4, 5, 6, 0], radius 128 -> 256"));
1412 }
1413}