Skip to main content

dream_ini/
openmw_cfg.rs

1// SPDX-License-Identifier: GPL-3.0-only
2
3use std::collections::BTreeSet;
4use std::fs::{self, OpenOptions};
5use std::io::{self, ErrorKind, Write};
6#[cfg(windows)]
7use std::os::windows::ffi::OsStrExt;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use openmw_config::{EncodingSetting, OpenMWConfiguration};
12
13use crate::{ImportError, MultiMap};
14
15static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Serializes cfg entries with `OpenMW` directory semantics and resolved directory paths.
18///
19/// # Errors
20/// Returns [`ImportError`] if the cfg cannot be represented as an `openmw-config` configuration.
21pub fn serialize_resolved_cfg(
22    cfg: &MultiMap,
23    user_config_dir: &Path,
24) -> Result<String, ImportError> {
25    Ok(serialize_resolved_configuration(
26        &configuration_from_multimap_resolved(cfg, user_config_dir)?,
27    ))
28}
29
30/// Writes cfg entries with `OpenMW` directory semantics and resolved directory paths.
31///
32/// # Errors
33/// Returns [`ImportError`] if the cfg cannot be represented as an `openmw-config` configuration or
34/// if writing the destination fails.
35pub fn save_resolved_cfg_to_path(cfg: &MultiMap, output_path: &Path) -> Result<(), ImportError> {
36    let user_config_dir = output_path.parent().unwrap_or_else(|| Path::new(""));
37    save_resolved_configuration_to_path(
38        &configuration_from_multimap_resolved(cfg, user_config_dir)?,
39        output_path,
40    )
41}
42
43/// Writes an `openmw-config` document with resolved paths without persisting composed engine VFS
44/// data directories.
45///
46/// # Errors
47/// Returns [`ImportError`] if serializing or writing the destination fails.
48pub fn save_resolved_configuration_to_path(
49    config: &OpenMWConfiguration,
50    output_path: &Path,
51) -> Result<(), ImportError> {
52    write_atomic(
53        output_path,
54        serialize_resolved_configuration(config).as_bytes(),
55    )?;
56    Ok(())
57}
58
59/// Writes the cfg layer that was loaded from `source_path` without flattening inherited configs.
60///
61/// # Errors
62/// Returns [`ImportError`] if writing the destination fails.
63pub fn save_preserved_cfg_document_to_path(
64    config: &OpenMWConfiguration,
65    source_path: &Path,
66    output_path: &Path,
67    update: &PreservedCfgUpdate,
68    changed_keys: &BTreeSet<String>,
69) -> Result<(), ImportError> {
70    let write_path = write_target_path(output_path);
71    write_atomic(
72        &write_path,
73        serialize_preserved_cfg_document(config, source_path, update, changed_keys).as_bytes(),
74    )?;
75    Ok(())
76}
77
78fn write_target_path(output_path: &Path) -> PathBuf {
79    fs::canonicalize(output_path).unwrap_or_else(|_| output_path.to_owned())
80}
81
82#[must_use]
83pub fn serialize_preserved_cfg_document(
84    config: &OpenMWConfiguration,
85    source_path: &Path,
86    update: &PreservedCfgUpdate,
87    changed_keys: &BTreeSet<String>,
88) -> String {
89    let source_path = source_path.to_path_buf();
90    let canonical_source_path = fs::canonicalize(&source_path).ok();
91    let mut write_keys = changed_keys.clone();
92    if update.data_local.is_some() {
93        write_keys.insert("data-local".to_owned());
94    }
95    if update.resources.is_some() {
96        write_keys.insert("resources".to_owned());
97    }
98    if update.user_data.is_some() {
99        write_keys.insert("user-data".to_owned());
100    }
101    let user_config_path = config.user_config_path().join("openmw.cfg");
102    let mut document = String::new();
103    for setting in config.settings_matching(|setting| {
104        let source = setting.meta().source_config();
105        source == source_path.as_path()
106            || canonical_source_path
107                .as_deref()
108                .is_some_and(|canonical_source_path| source == canonical_source_path)
109            || (source == user_config_path
110                && setting_key(setting).is_some_and(|key| write_keys.contains(&key)))
111    }) {
112        document.push_str(&setting.to_string());
113    }
114    document
115}
116
117fn setting_key(setting: &impl ToString) -> Option<String> {
118    let text = setting.to_string();
119    text.lines()
120        .last()?
121        .split_once('=')
122        .map(|(key, _)| key.to_owned())
123}
124
125/// Import changes that should be applied to a preserving `openmw.cfg` document.
126#[derive(Debug, Clone)]
127pub struct PreservedCfgUpdate {
128    pub import_game_files: bool,
129    pub import_archives: bool,
130    pub data_local: Option<PathBuf>,
131    pub resources: Option<PathBuf>,
132    pub user_data: Option<PathBuf>,
133}
134
135/// Loads an `OpenMW` cfg document without flattening it through resolved serialization.
136///
137/// # Errors
138/// Returns [`ImportError`] if `openmw-config` cannot load the requested cfg chain.
139pub fn load_cfg_document(path: &Path) -> Result<OpenMWConfiguration, ImportError> {
140    OpenMWConfiguration::load_optional(path).map_err(|error| config_error(&error))
141}
142
143/// Serializes cfg entries with `OpenMW` directory semantics while preserving authored path spelling.
144///
145/// # Errors
146/// Returns [`ImportError`] if the cfg cannot be represented as an `openmw-config` configuration.
147pub fn serialize_cfg_output(cfg: &MultiMap, user_config_dir: &Path) -> Result<String, ImportError> {
148    Ok(configuration_from_multimap_preserving(cfg, user_config_dir)?.to_string())
149}
150
151/// Writes cfg entries with `OpenMW` directory semantics while preserving authored path spelling.
152///
153/// # Errors
154/// Returns [`ImportError`] if the cfg cannot be represented as an `openmw-config` configuration or
155/// if writing the destination fails.
156pub fn save_cfg_output_to_path(cfg: &MultiMap, output_path: &Path) -> Result<(), ImportError> {
157    let user_config_dir = output_path.parent().unwrap_or_else(|| Path::new(""));
158    write_atomic(
159        output_path,
160        configuration_from_multimap_preserving(cfg, user_config_dir)?
161            .to_string()
162            .as_bytes(),
163    )?;
164    Ok(())
165}
166
167/// Applies imported cfg values to an existing preserving `openmw-config` document.
168///
169/// # Errors
170/// Returns [`ImportError`] if fallback or encoding values cannot be represented by
171/// `openmw-config`.
172pub fn apply_preserved_cfg_update(
173    config: &mut OpenMWConfiguration,
174    imported_cfg: &MultiMap,
175    update: &PreservedCfgUpdate,
176    changed_keys: &BTreeSet<String>,
177) -> Result<(), ImportError> {
178    if changed_keys.contains("encoding")
179        && let Some(encoding) = imported_cfg
180            .get("encoding")
181            .and_then(|values| values.last())
182    {
183        set_encoding(config, encoding)?;
184    }
185    if changed_keys.contains("no-sound") {
186        config.set_generic_settings("no-sound", imported_cfg.get("no-sound").cloned());
187    }
188    if changed_keys.contains("fallback") {
189        config
190            .set_game_settings(imported_cfg.get("fallback").cloned())
191            .map_err(|error| config_error(&error))?;
192    }
193
194    if changed_keys.contains("data") {
195        for data_dir in imported_cfg.get("data").into_iter().flatten() {
196            if !config.has_data_dir(data_dir) {
197                config.add_data_directory(Path::new(data_dir));
198            }
199        }
200    }
201
202    if update.import_game_files && changed_keys.contains("content") {
203        config.set_content_files(imported_cfg.get("content").cloned());
204    }
205    if update.import_archives && changed_keys.contains("fallback-archive") {
206        config.set_fallback_archives(imported_cfg.get("fallback-archive").cloned());
207    }
208    if let Some(path) = &update.data_local {
209        clear_preserved_key(config, "data-local");
210        config.set_data_local_path(path);
211    }
212    if let Some(path) = &update.resources {
213        clear_preserved_key(config, "resources");
214        config.set_resources_path(path);
215    }
216    if let Some(path) = &update.user_data {
217        clear_preserved_key(config, "user-data");
218        config.set_user_data_path(path);
219    }
220
221    Ok(())
222}
223
224pub(crate) fn load_resolved_cfg(path: &Path) -> Result<MultiMap, ImportError> {
225    let config = OpenMWConfiguration::load_optional(path).map_err(|error| config_error(&error))?;
226    let mut cfg = crate::parse_cfg_str(&config.to_resolved_string());
227    remove_composed_non_import_data_dirs(&mut cfg);
228    Ok(cfg)
229}
230
231pub(crate) fn normalize_cfg(
232    cfg: &MultiMap,
233    user_config_dir: Option<&Path>,
234) -> Result<MultiMap, ImportError> {
235    let Some(user_config_dir) = user_config_dir else {
236        return Ok(cfg.clone());
237    };
238    let mut cfg = crate::parse_cfg_str(
239        &configuration_from_multimap_resolved(cfg, user_config_dir)?.to_resolved_string(),
240    );
241    remove_composed_non_import_data_dirs(&mut cfg);
242    Ok(cfg)
243}
244
245#[must_use]
246pub fn serialize_resolved_configuration(config: &OpenMWConfiguration) -> String {
247    let mut cfg = crate::parse_cfg_str(&config.to_resolved_string());
248    remove_composed_non_import_data_dirs(&mut cfg);
249    crate::serialize_cfg(&cfg)
250}
251
252fn configuration_from_multimap_preserving(
253    cfg: &MultiMap,
254    user_config_dir: &Path,
255) -> Result<OpenMWConfiguration, ImportError> {
256    let user_config_dir = effective_user_config_dir(user_config_dir);
257    let mut config =
258        OpenMWConfiguration::new_empty(&user_config_dir).map_err(|error| config_error(&error))?;
259
260    for (key, values) in cfg {
261        match key.as_str() {
262            "data" => config.set_data_directories(Some(paths(values))),
263            "data-local" | "resources" | "user-data" => {
264                config.set_generic_settings(key, Some(values.clone()));
265            }
266            "content" => config.set_content_files(Some(values.clone())),
267            "fallback-archive" => config.set_fallback_archives(Some(values.clone())),
268            "fallback" => config
269                .set_game_settings(Some(values.clone()))
270                .map_err(|error| config_error(&error))?,
271            other => config.set_generic_settings(other, Some(values.clone())),
272        }
273    }
274
275    Ok(config)
276}
277
278fn configuration_from_multimap_resolved(
279    cfg: &MultiMap,
280    user_config_dir: &Path,
281) -> Result<OpenMWConfiguration, ImportError> {
282    let user_config_dir = effective_user_config_dir(user_config_dir);
283    let mut config =
284        OpenMWConfiguration::new_empty(&user_config_dir).map_err(|error| config_error(&error))?;
285
286    for (key, values) in cfg {
287        match key.as_str() {
288            "data" => config.set_data_directories(Some(paths(values))),
289            "data-local" => set_last_path(values, |path| config.set_data_local_path(path)),
290            "resources" => set_last_path(values, |path| config.set_resources_path(path)),
291            "user-data" => set_last_path(values, |path| config.set_user_data_path(path)),
292            "content" => config.set_content_files(Some(values.clone())),
293            "fallback-archive" => config.set_fallback_archives(Some(values.clone())),
294            "fallback" => config
295                .set_game_settings(Some(values.clone()))
296                .map_err(|error| config_error(&error))?,
297            other => config.set_generic_settings(other, Some(values.clone())),
298        }
299    }
300
301    Ok(config)
302}
303
304fn effective_user_config_dir(path: &Path) -> PathBuf {
305    if path.as_os_str().is_empty() {
306        return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
307    }
308
309    path.to_owned()
310}
311
312fn paths(values: &[String]) -> Vec<PathBuf> {
313    values.iter().map(PathBuf::from).collect()
314}
315
316fn set_last_path<F>(values: &[String], mut set: F)
317where
318    F: FnMut(&Path),
319{
320    if let Some(value) = values.last() {
321        set(Path::new(value));
322    }
323}
324
325fn set_encoding(config: &mut OpenMWConfiguration, encoding: &str) -> Result<(), ImportError> {
326    clear_preserved_key(config, "encoding");
327    let cfg_path = config.user_config_path().join("openmw.cfg");
328    let mut comment = String::new();
329    let setting = EncodingSetting::try_from((encoding.to_owned(), cfg_path, &mut comment))
330        .map_err(|error| config_error(&error))?;
331    config.set_encoding(Some(setting));
332    Ok(())
333}
334
335fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ImportError> {
336    let parent = path
337        .parent()
338        .filter(|parent| !parent.as_os_str().is_empty())
339        .unwrap_or_else(|| Path::new("."));
340
341    for _ in 0..16 {
342        let temp_path = temporary_path_for(path);
343        let file = match OpenOptions::new()
344            .write(true)
345            .create_new(true)
346            .open(&temp_path)
347        {
348            Ok(file) => file,
349            Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
350            Err(source) => {
351                return Err(ImportError::Io {
352                    path: path.to_owned(),
353                    source,
354                });
355            }
356        };
357
358        if let Err(source) = finish_atomic_write(path, parent, &temp_path, file, bytes) {
359            let _ = fs::remove_file(&temp_path);
360            return Err(ImportError::Io {
361                path: path.to_owned(),
362                source,
363            });
364        }
365
366        return Ok(());
367    }
368
369    Err(ImportError::Io {
370        path: path.to_owned(),
371        source: io::Error::new(
372            ErrorKind::AlreadyExists,
373            "could not create a unique temporary cfg file",
374        ),
375    })
376}
377
378fn finish_atomic_write(
379    path: &Path,
380    parent: &Path,
381    temp_path: &Path,
382    mut file: fs::File,
383    bytes: &[u8],
384) -> io::Result<()> {
385    if let Ok(metadata) = fs::metadata(path) {
386        // Preserve the portable permission bits we can apply before replacement. This is not a
387        // promise to preserve ownership, ACLs, xattrs, or timestamps; atomic replacement creates a
388        // new file object, because of course it does.
389        file.set_permissions(metadata.permissions())?;
390    }
391    file.write_all(bytes)?;
392    file.sync_all()?;
393    drop(file);
394    replace_file(temp_path, path)?;
395    sync_parent_dir(parent)
396}
397
398#[cfg(not(windows))]
399fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
400    fs::rename(source, destination)
401}
402
403#[cfg(windows)]
404fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
405    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
406    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
407
408    unsafe extern "system" {
409        fn MoveFileExW(
410            existing_file_name: *const u16,
411            new_file_name: *const u16,
412            flags: u32,
413        ) -> i32;
414    }
415
416    let source = wide_null(source);
417    let destination = wide_null(destination);
418    let result = unsafe {
419        MoveFileExW(
420            source.as_ptr(),
421            destination.as_ptr(),
422            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
423        )
424    };
425    if result == 0 {
426        Err(io::Error::last_os_error())
427    } else {
428        Ok(())
429    }
430}
431
432#[cfg(windows)]
433fn wide_null(path: &Path) -> Vec<u16> {
434    path.as_os_str().encode_wide().chain([0]).collect()
435}
436
437fn temporary_path_for(path: &Path) -> PathBuf {
438    let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
439    let file_name = path
440        .file_name()
441        .and_then(|name| name.to_str())
442        .unwrap_or("openmw.cfg");
443    let temp_name = format!(
444        ".{file_name}.dream-ini-{}-{counter}.tmp",
445        std::process::id()
446    );
447    path.with_file_name(temp_name)
448}
449
450#[cfg(unix)]
451fn sync_parent_dir(parent: &Path) -> io::Result<()> {
452    fs::File::open(parent)?.sync_all()
453}
454
455#[cfg(not(unix))]
456fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
457    Ok(())
458}
459
460fn clear_preserved_key(config: &mut OpenMWConfiguration, key: &str) {
461    let prefix = format!("{key}=");
462    config.clear_matching(|setting| {
463        setting
464            .to_string()
465            .lines()
466            .last()
467            .is_some_and(|line| line.starts_with(&prefix))
468    });
469}
470
471fn remove_composed_non_import_data_dirs(cfg: &mut MultiMap) {
472    // openmw-config 1.0.5 serializes some singleton directory settings as composed data= entries
473    // in resolved output. They are not Morrowind.exe content inputs for dream-ini and must not be
474    // persisted as authored data= entries. Keep their singleton keys as the source of truth.
475    remove_composed_data_dir(cfg, "data-local", Path::to_owned);
476    remove_composed_data_dir(cfg, "resources", |path| path.join("vfs"));
477}
478
479fn remove_composed_data_dir<F>(cfg: &mut MultiMap, key: &str, mut composed_path: F)
480where
481    F: FnMut(&Path) -> PathBuf,
482{
483    let Some(value) = cfg.get(key).and_then(|values| values.last()) else {
484        return;
485    };
486    let composed = composed_path(Path::new(value))
487        .to_string_lossy()
488        .into_owned();
489
490    if let Some(data_dirs) = cfg.get_mut("data") {
491        data_dirs.retain(|data_dir| data_dir != &composed);
492    }
493}
494
495fn config_error(error: &openmw_config::ConfigError) -> ImportError {
496    ImportError::OpenMwConfig(error.to_string())
497}