pub mod dependency;
pub mod descriptor;
mod error;
pub mod steam;
pub use error::{ParseError, ParseErrorKind};
use crate::game::eu4::Eu4;
use crate::model::GamePath;
use descriptor::{LauncherDescriptor, load_launcher_descriptor};
use relative_path::RelativePath;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use steam::WorkshopInstallIdentity;
#[derive(Debug, Clone, Default)]
pub struct Playset {
pub game: Eu4,
pub name: String,
pub mods: Vec<PlaysetEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct PlaysetEntry {
pub id: Option<String>,
pub display_name: Option<String>,
pub enabled: bool,
pub position: Option<usize>,
pub steam_id: Option<String>,
pub workshop_identity: Option<WorkshopInstallIdentity>,
pub root_path: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct DlcLoad {
#[serde(default)]
enabled_mods: Vec<String>,
#[serde(default)]
#[allow(dead_code)] disabled_dlcs: Vec<String>,
}
impl Playset {
pub fn from_dlc_load(path: &Path) -> Result<Self, ParseError> {
Self::from_dlc_load_impl(path, None)
}
pub(crate) fn from_dlc_load_with_required_descriptors(
path: &Path,
) -> Result<(Self, Vec<MissingLauncherDescriptor>), ParseError> {
let mut missing = Vec::new();
let playset = Self::from_dlc_load_impl(path, Some(&mut missing))?;
Ok((playset, missing))
}
fn from_dlc_load_impl(
path: &Path,
mut required: Option<&mut Vec<MissingLauncherDescriptor>>,
) -> Result<Self, ParseError> {
let bytes = std::fs::read(path).map_err(|err| ParseError::io(path.to_path_buf(), err))?;
let dlc: DlcLoad = serde_json::from_slice(&bytes)
.map_err(|err| ParseError::format(path.to_path_buf(), err.to_string()))?;
let parent = path
.parent()
.ok_or_else(|| {
ParseError::format(
path.to_path_buf(),
"dlc_load.json must live inside a paradox game data directory".to_string(),
)
})?
.to_path_buf();
let game = Eu4;
let mut mods = Vec::with_capacity(dlc.enabled_mods.len());
for (position, rel) in dlc.enabled_mods.iter().enumerate() {
let rel = parse_enabled_mod_entry(path, rel)?;
let entry = match required.as_deref_mut() {
Some(missing) => read_dlc_load_entry_required(&parent, position, rel, missing)?,
None => read_dlc_load_entry(&parent, position, rel)?,
};
mods.push(entry);
}
let name = match path.file_stem().and_then(|s| s.to_str()) {
Some(stem) if !stem.is_empty() => format!("{stem} (active)"),
_ => "active".to_string(),
};
Ok(Playset { game, name, mods })
}
}
fn parse_enabled_mod_entry<'a>(
dlc_load: &Path,
rel: &'a str,
) -> Result<&'a RelativePath, ParseError> {
GamePath::new(rel)
.map(GamePath::as_relative_path)
.map_err(|error| {
ParseError::format(
dlc_load.to_path_buf(),
format!(
"enabled mod descriptor path `{rel}` must be normalized and relative: {}",
error.kind
),
)
})
}
fn enabled_mod_descriptor_path(paradox_data_dir: &Path, rel: &RelativePath) -> PathBuf {
rel.iter()
.fold(paradox_data_dir.to_path_buf(), |path, name| path.join(name))
}
fn read_dlc_load_entry(
paradox_data_dir: &Path,
position: usize,
rel: &RelativePath,
) -> Result<PlaysetEntry, ParseError> {
let path = enabled_mod_descriptor_path(paradox_data_dir, rel);
let descriptor = match std::fs::symlink_metadata(&path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(ParseError::io(path, err)),
Ok(_) => Some(load_launcher_descriptor(&path)?),
};
playset_entry_from_descriptor(&path, position, rel, descriptor)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct MissingLauncherDescriptor {
pub position: usize,
pub path: PathBuf,
}
fn read_dlc_load_entry_required(
paradox_data_dir: &Path,
position: usize,
rel: &RelativePath,
missing: &mut Vec<MissingLauncherDescriptor>,
) -> Result<PlaysetEntry, ParseError> {
let path = enabled_mod_descriptor_path(paradox_data_dir, rel);
if let Err(error) = std::fs::symlink_metadata(&path)
&& error.kind() == std::io::ErrorKind::NotFound
{
missing.push(MissingLauncherDescriptor {
position,
path: path.clone(),
});
return playset_entry_from_descriptor(&path, position, rel, None);
}
let descriptor = load_launcher_descriptor(&path)?;
playset_entry_from_descriptor(&path, position, rel, Some(descriptor))
}
fn playset_entry_from_descriptor(
descriptor_path: &Path,
position: usize,
rel: &RelativePath,
descriptor: Option<LauncherDescriptor>,
) -> Result<PlaysetEntry, ParseError> {
let remote_file_id = descriptor
.as_ref()
.and_then(|d| d.remote_file_id.clone())
.filter(|id| !id.trim().is_empty());
if let Some(id) = remote_file_id.as_deref()
&& single_path_component(id).is_none()
{
return Err(ParseError::format(
descriptor_path.to_path_buf(),
format!("remote_file_id `{id}` must be a single plain path component"),
));
}
let steam_id = remote_file_id.or_else(|| extract_steam_id_from_descriptor_path(rel));
let display_name = descriptor
.as_ref()
.and_then(|d| (!d.name.trim().is_empty()).then(|| d.name.clone()))
.or_else(|| steam_id.as_ref().map(|id| format!("ugc_{id}")));
Ok(PlaysetEntry {
id: None,
display_name,
enabled: true,
position: Some(position),
steam_id,
workshop_identity: None,
root_path: None,
})
}
pub(crate) fn single_path_component(text: &str) -> Option<&str> {
if text.is_empty() || text.contains(['/', '\\', ':', '\0']) {
return None;
}
let mut components = Path::new(text).components();
match (components.next(), components.next()) {
(Some(std::path::Component::Normal(name)), None) if name == text => Some(text),
_ => None,
}
}
fn extract_steam_id_from_descriptor_path(rel: &RelativePath) -> Option<String> {
let filename = rel.file_stem()?;
let stripped = filename.strip_prefix("ugc_")?;
if stripped.chars().all(|c| c.is_ascii_digit()) && !stripped.is_empty() {
Some(stripped.to_string())
} else {
None
}
}
pub fn default_dlc_load_path(paradox_data_dir: &Path) -> PathBuf {
paradox_data_dir.join("dlc_load.json")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_dlc_load(dir: &Path, mods: &[(&str, &str)]) {
fs::create_dir_all(dir.join("mod")).unwrap();
let entries: Vec<String> = mods
.iter()
.map(|(steam_id, _)| format!("mod/ugc_{steam_id}.mod"))
.collect();
let payload =
serde_json::json!({ "enabled_mods": entries, "disabled_dlcs": Vec::<String>::new() });
fs::write(
dir.join("dlc_load.json"),
serde_json::to_string_pretty(&payload).unwrap(),
)
.unwrap();
for (steam_id, name) in mods {
let body = format!(
"name=\"{name}\"\npath=\"/tmp/mod_{steam_id}\"\nremote_file_id=\"{steam_id}\"\n"
);
fs::write(dir.join("mod").join(format!("ugc_{steam_id}.mod")), body).unwrap();
}
}
#[test]
fn parses_dlc_load_with_descriptors() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(&game_dir).unwrap();
write_dlc_load(
&game_dir,
&[("2164202838", "Europa Expanded"), ("1999055990", "汉化")],
);
let playlist = Playset::from_dlc_load(&game_dir.join("dlc_load.json")).unwrap();
assert_eq!(playlist.game, Eu4);
assert_eq!(playlist.mods.len(), 2);
assert_eq!(playlist.mods[0].steam_id.as_deref(), Some("2164202838"));
assert_eq!(
playlist.mods[0].display_name.as_deref(),
Some("Europa Expanded")
);
assert_eq!(playlist.mods[0].position, Some(0));
assert!(playlist.mods[0].enabled);
assert_eq!(playlist.mods[1].steam_id.as_deref(), Some("1999055990"));
assert_eq!(playlist.mods[1].display_name.as_deref(), Some("汉化"));
assert_eq!(playlist.mods[1].position, Some(1));
}
#[test]
fn falls_back_to_filename_when_descriptor_missing() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(&game_dir).unwrap();
fs::write(
game_dir.join("dlc_load.json"),
r#"{"enabled_mods":["mod/ugc_999.mod"],"disabled_dlcs":[]}"#,
)
.unwrap();
let playlist = Playset::from_dlc_load(&game_dir.join("dlc_load.json")).unwrap();
assert_eq!(playlist.mods.len(), 1);
assert_eq!(playlist.mods[0].steam_id.as_deref(), Some("999"));
assert_eq!(playlist.mods[0].display_name.as_deref(), Some("ugc_999"));
let (strict, missing) =
Playset::from_dlc_load_with_required_descriptors(&game_dir.join("dlc_load.json"))
.expect("a missing descriptor does not fail the playset");
assert_eq!(strict.mods[0].steam_id.as_deref(), Some("999"));
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].position, 0);
assert!(missing[0].path.ends_with("mod/ugc_999.mod"));
}
#[test]
fn an_unreadable_or_unparseable_descriptor_is_reported_with_its_path() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(game_dir.join("mod").join("ugc_1.mod")).unwrap();
fs::write(game_dir.join("mod").join("ugc_2.mod"), "name = { a = ").unwrap();
for (id, expected_kind) in [("1", ParseErrorKind::Io), ("2", ParseErrorKind::Format)] {
let dlc_load = game_dir.join("dlc_load.json");
fs::write(
&dlc_load,
format!(r#"{{"enabled_mods":["mod/ugc_{id}.mod"],"disabled_dlcs":[]}}"#),
)
.unwrap();
let error = Playset::from_dlc_load(&dlc_load).expect_err(id);
assert_eq!(error.kind, expected_kind, "{error}");
assert!(error.path.ends_with(format!("mod/ugc_{id}.mod")), "{error}");
}
}
#[test]
fn a_remote_file_id_that_is_not_one_path_component_is_rejected() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
write_dlc_load(&game_dir, &[("1001", "Named")]);
let dlc_load = game_dir.join("dlc_load.json");
let launcher_copy = game_dir.join("mod").join("ugc_1001.mod");
for bad in ["..", "../../x", "a/b", "/abs"] {
fs::write(
&launcher_copy,
format!("name=\"Named\"\nremote_file_id=\"{bad}\"\n"),
)
.unwrap();
for result in [
Playset::from_dlc_load(&dlc_load),
Playset::from_dlc_load_with_required_descriptors(&dlc_load)
.map(|(playset, _missing)| playset),
] {
let error = result.expect_err(bad);
assert_eq!(error.kind, ParseErrorKind::Format, "{error}");
assert!(error.path.ends_with("mod/ugc_1001.mod"), "{error}");
}
}
}
#[test]
fn single_path_component_accepts_only_one_plain_name() {
for good in ["1001", "My Mod", "汉化", "mod.v2"] {
assert_eq!(single_path_component(good), Some(good));
}
for bad in [
"", ".", "..", "a/b", r"a\b", "/abs", "a/", "./a", "a\0b", r"C:\x", "C:",
] {
assert_eq!(single_path_component(bad), None, "{bad:?}");
}
}
#[test]
fn a_launcher_descriptor_replace_path_does_not_affect_the_playset_entry() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
write_dlc_load(&game_dir, &[("1001", "Named")]);
let launcher_copy = game_dir.join("mod").join("ugc_1001.mod");
let mut body = fs::read_to_string(&launcher_copy).unwrap();
body.push_str("replace_path=\"common/../events\"\n");
fs::write(&launcher_copy, body).unwrap();
let dlc_load = game_dir.join("dlc_load.json");
for playset in [
Playset::from_dlc_load(&dlc_load).unwrap(),
Playset::from_dlc_load_with_required_descriptors(&dlc_load)
.unwrap()
.0,
] {
assert_eq!(playset.mods[0].display_name.as_deref(), Some("Named"));
assert_eq!(playset.mods[0].steam_id.as_deref(), Some("1001"));
}
}
#[test]
fn strict_loader_rejects_descriptor_path_escape() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(&game_dir).unwrap();
fs::write(
temp.path().join("outside.mod"),
"name=\"Outside\"\nremote_file_id=\"999\"\n",
)
.unwrap();
fs::write(
game_dir.join("dlc_load.json"),
r#"{"enabled_mods":["../outside.mod"],"disabled_dlcs":[]}"#,
)
.unwrap();
let error =
Playset::from_dlc_load_with_required_descriptors(&game_dir.join("dlc_load.json"))
.expect_err("strict loader must reject paths escaping the game data directory");
assert!(
error
.to_string()
.contains("must be normalized and relative")
);
}
#[test]
fn both_loaders_reject_entries_outside_the_launcher_slash_syntax() {
let temp = TempDir::new().unwrap();
let game_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(game_dir.join("mod")).unwrap();
fs::write(
temp.path().join("outside.mod"),
"name=\"Outside\"\nremote_file_id=\"999\"\n",
)
.unwrap();
fs::write(
game_dir.join("mod").join("ugc_999.mod"),
"name=\"Inside\"\nremote_file_id=\"999\"\n",
)
.unwrap();
let dlc_load = game_dir.join("dlc_load.json");
for entry in [
"../outside.mod",
"/mod/ugc_999.mod",
r"mod\ugc_999.mod",
"mod//ugc_999.mod",
"./mod/ugc_999.mod",
"",
] {
fs::write(
&dlc_load,
serde_json::json!({ "enabled_mods": [entry], "disabled_dlcs": [] }).to_string(),
)
.unwrap();
for error in [
Playset::from_dlc_load(&dlc_load).expect_err(entry),
Playset::from_dlc_load_with_required_descriptors(&dlc_load).expect_err(entry),
] {
assert_eq!(error.kind, ParseErrorKind::Format, "{entry}");
assert_eq!(error.path, dlc_load, "{entry}");
assert!(
error
.message
.contains(&format!("`{entry}` must be normalized and relative")),
"{entry}: {error}"
);
assert!(
!error.message.contains("game path"),
"a launcher entry is not a game path: {error}"
);
}
}
}
#[test]
fn unknown_paradox_data_dir_defaults_to_eu4() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("dlc_load.json");
fs::write(&path, r#"{"enabled_mods":[],"disabled_dlcs":[]}"#).unwrap();
let playlist = Playset::from_dlc_load(&path).unwrap();
assert_eq!(playlist.game, Eu4);
}
}