Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b468a81
fix(core): make copy-up atomic
MotherSphere Oct 7, 2026
0b33287
fix(instance): forget removed mods so a batch remove cannot wedge the…
MotherSphere Oct 7, 2026
d32e9e8
fix(instance): put unlisted mod folders at the highest priority
MotherSphere Oct 7, 2026
e001f17
fix(transfer): report skipped symlinks and unreadable folders as an i…
MotherSphere Oct 7, 2026
72c0482
fix(ini): stop a UTF-8 BOM from hiding an INI's first section
MotherSphere Oct 7, 2026
6a9eba0
fix(plugins): seed the profile from the game's real state before writ…
MotherSphere Oct 7, 2026
b629533
fix(gui): refuse to save a mod list or load order another process cha…
MotherSphere Oct 7, 2026
0693476
fix(saves): stop profiles sharing a prefix from rescuing each other's…
MotherSphere Oct 7, 2026
323dbe2
fix(collections): let a new revision take over the previous revision'…
MotherSphere Oct 7, 2026
2410c58
fix(gui): stop every mod toggle from walking every mod's files twice
MotherSphere Oct 7, 2026
134bc64
fix(gui): stop the Data tab filter freezing on large mod lists
MotherSphere Oct 7, 2026
7a17451
fix(instance): delete modlist.txt instead of saving it empty
MotherSphere Oct 7, 2026
b405648
fix(gui): check the mod list is current before renaming or creating a…
MotherSphere Oct 7, 2026
384513a
fix(gui): re-read the load order after the INI editor saves Morrowind…
MotherSphere Oct 7, 2026
f5585ff
fix(collections): let an older revision take its folders back
MotherSphere Oct 7, 2026
310191f
fix(collections): carry an unchanged member's status to the next revi…
MotherSphere Oct 7, 2026
129e3d7
fix(collections): carry the INI Tweaks folder over to the next revision
MotherSphere Oct 7, 2026
576912b
fix(collections): take over a previous revision only once the install…
MotherSphere Oct 7, 2026
dd032d2
fix(collections): do not take over a member the user skipped in the n…
MotherSphere Oct 7, 2026
064dc9b
fix(collections): leave a folder another profile enables to that profile
MotherSphere Oct 7, 2026
853197c
fix(collections): stop naming a leftover folder once the user disable…
MotherSphere Oct 7, 2026
98fce7a
fix(collections): fall back to a fresh copy when a revision cannot ta…
MotherSphere Oct 7, 2026
7e036e0
fix(collections): make an adopted INI Tweaks folder mirror the new re…
MotherSphere Oct 7, 2026
cfe2d98
fix(gui): keep mod-list refusals visible and other profiles in step
MotherSphere Oct 7, 2026
ff89389
fix(saves): warn about hidden prefix saves once per change
MotherSphere Oct 7, 2026
0c3c141
fix(transfer): say how to pack without a linked downloads folder
MotherSphere Oct 7, 2026
06638f1
docs(guide): document the mod list, profile saves and collection changes
MotherSphere Oct 7, 2026
eae9ced
fix(collections): never delete an INI fragment the user added
MotherSphere Oct 7, 2026
5a99139
docs(i18n): bring the usage guide translations up to date
MotherSphere Oct 7, 2026
e21c866
fix(collections): keep a member folder the user changed before replac…
MotherSphere Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

745 changes: 727 additions & 18 deletions crates/eidos-collections/src/driver.rs

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions crates/eidos-collections/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ pub trait Hooks {
fn allow_runtime_mismatch(&self) -> bool {
false
}
/// Take over what another revision of this collection installed, once the
/// recipe and the runtime have passed and before the first member. Returns
/// what the report has to name.
fn adopt(
&mut self,
_c: &Collection,
_state: &mut InstallState,
_save: &mut dyn FnMut(&InstallState) -> Result<(), String>,
) -> Result<Vec<Note>, String> {
Ok(Vec::new())
}
/// Get this member's archive, or say why not.
fn obtain(&mut self, m: &Mod) -> Obtained;
/// Reserve an owned destination before the engine persists it and starts extraction.
Expand Down Expand Up @@ -155,6 +166,19 @@ pub fn run(
}),
_ => {}
}
// Only now: a revision stopped by either gate above must not have taken the
// folders of the revision the user stays on.
match hooks.adopt(c, state, save) {
Ok(notes) => report.deferred.extend(notes),
Err(error) => {
report.aborted = true;
report.failed.push(Note {
subject: "previous revision".into(),
detail: format!("its folders could not be taken over: {error}"),
});
return report;
}
}
let order = install_order(c);
let total = order.len();

Expand Down Expand Up @@ -506,6 +530,42 @@ mod tests {
);
}

#[test]
fn a_revision_stopped_at_the_runtime_gate_takes_nothing_over() {
struct Gated {
allow: bool,
adopted: usize,
}
impl Hooks for Gated {
fn runtime_check(&mut self, _: &Collection) -> crate::recipe::RuntimeCheck {
crate::recipe::RuntimeCheck::Mismatch { observed: "new".into(), expected: vec!["old".into()] }
}
fn allow_runtime_mismatch(&self) -> bool {
self.allow
}
fn adopt(&mut self, _: &Collection, _: &mut InstallState, _: &mut dyn FnMut(&InstallState) -> Result<(), String>) -> Result<Vec<Note>, String> {
self.adopted += 1;
Ok(vec![Note { subject: "Old".into(), detail: "unused".into() }])
}
fn obtain(&mut self, _: &Mod) -> Obtained {
assert_eq!(self.adopted, 1, "adoption comes before the first member");
Obtained::Ready("archive".into())
}
fn install(&mut self, m: &Mod, _: &std::path::Path, _: &str) -> Installed {
Installed::Ok(m.name.clone())
}
}
let c = collection(vec![member("A", 0, false, 1)]);
let mut hooks = Gated { allow: false, adopted: 0 };
// The user may stay on the revision they have; it keeps its folders.
assert!(run(&c, &mut InstallState::default(), &mut hooks, &mut noop).aborted);
assert_eq!(hooks.adopted, 0);
hooks.allow = true;
let report = run(&c, &mut InstallState::default(), &mut hooks, &mut noop);
assert_eq!(hooks.adopted, 1);
assert!(report.deferred.iter().any(|n| n.subject == "Old"));
}

#[test]
fn members_install_by_phase_then_by_the_order_the_collection_listed_them() {
let c = collection(vec![
Expand Down
46 changes: 46 additions & 0 deletions crates/eidos-collections/src/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,3 +707,49 @@ pub fn verify_receipt(m: &Mod, payload: &Path, folder: &Path, owner: &str) -> Re
&& receipt.recipe == identity(m, payload)?
&& receipt.files == tree_digests(folder)?)
}
/// Whether `folder` holds files its install receipt does not vouch for: a file
/// the user edited, added or hid, files moved in by Sync to Mods, or content
/// with no readable receipt at all. A fresh reservation holds nothing to lose.
pub fn holds_unreceipted_files(folder: &Path) -> Result<bool, String> {
let current = tree_digests(folder)?;
if current.is_empty() {
return Ok(false);
}
let path = folder.join(RECEIPT);
match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_file() => {}
_ => return Ok(true),
}
Ok(
match serde_json::from_slice::<Receipt>(&bounded_read(&path, 64 * 1024 * 1024)?) {
Ok(receipt) => receipt.files != current,
Err(_) => true,
},
)
}
/// Hand a receipt to the next revision of the same collection.
///
/// The owner is part of what a receipt proves, so without this every member a
/// new revision takes over would fail verification and be extracted again -
/// downloaded again too, when `downloads/` was cleaned. The recipe and file
/// digests are untouched and still checked against the new revision's member,
/// so a member the author changed is replaced as before. A receipt that is
/// missing, unreadable or names any other owner is left alone, and
/// verification rejects it.
pub fn transfer_receipt(folder: &Path, from: &str, to: &str) -> Result<(), String> {
let path = folder.join(RECEIPT);
if !fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
return Ok(());
}
let Ok(mut receipt) =
serde_json::from_slice::<Receipt>(&bounded_read(&path, 64 * 1024 * 1024)?)
else {
return Ok(());
};
if receipt.owner != from {
return Ok(());
}
receipt.owner = to.into();
let bytes = serde_json::to_vec(&receipt).map_err(|e| e.to_string())?;
eidos_instance::write_atomic(&path, &bytes).map_err(|e| e.to_string())
}
8 changes: 8 additions & 0 deletions crates/eidos-collections/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ pub struct Report {
/// Members installed under a different folder name because a mod that is
/// not this collection's already had that one.
pub renamed: Vec<Note>,
/// Members whose previous folder differed from its install receipt, so it
/// was kept beside the replacement instead of deleted.
pub kept: Vec<Note>,
/// Unverified requirements and remaining collection-wide choices, including
/// unknown runtime evidence, an approved runtime mismatch, and INI selection.
pub deferred: Vec<Note>,
Expand Down Expand Up @@ -129,6 +132,11 @@ impl Report {
"Installed under a different name, because a mod of yours already had it:",
&self.renamed,
);
section(
&mut out,
"Replaced, with the previous folder kept beside it:",
&self.kept,
);
section(&mut out, "Load order:", &self.loot_notes);
section(
&mut out,
Expand Down
1 change: 1 addition & 0 deletions crates/eidos-collections/tests/downloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn a_direct_member_preserves_unrelated_downloads_and_partials() {
collection_domain: def.nexus_game.into(),
owner: "test:1".into(),
renamed: vec![],
kept: vec![],
};
for (name, suffix) in [
("Complete", ""),
Expand Down
1 change: 1 addition & 0 deletions crates/eidos-collections/tests/installer_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ print(json.dumps({'protocol':1,'request_id':r['request_id'],'outcome':o}))
collection_domain: "oblivion".into(),
owner: "fixture:1".into(),
renamed: vec![],
kept: vec![],
payload_root: root.clone(),
allow_runtime_mismatch: false,
};
Expand Down
73 changes: 73 additions & 0 deletions crates/eidos-collections/tests/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ fn a_previously_unavailable_member_does_not_own_an_existing_personal_mod() {
collection_domain: "skyrimspecialedition".into(),
owner: "test:1".into(),
renamed: vec![],
kept: vec![],
};
let folder = hooks
.reserve(
Expand Down Expand Up @@ -254,6 +255,7 @@ fn a_stale_download_sidecar_does_not_hide_a_later_complete_archive() {
collection_domain: "skyrimspecialedition".into(),
owner: "test:1".into(),
renamed: vec![],
kept: vec![],
};
assert_eq!(
hooks.obtain(&member),
Expand Down Expand Up @@ -348,6 +350,7 @@ fn bundle_clone_patch_pipeline_resumes_and_failed_replacement_retains_owned_and_
collection_domain: c.info.domain_name.clone(),
owner: "synthetic:1".into(),
renamed: vec![],
kept: vec![],
};
let mut state = InstallState::default();
let mut durable = state.clone();
Expand Down Expand Up @@ -459,6 +462,7 @@ fn omod_recipes_patch_before_publication_and_hashes_use_decoded_sources() {
collection_domain: "oblivion".into(),
owner: "omod:1".into(),
renamed: vec![],
kept: vec![],
};
for (name, hashes) in [
("Plain", vec![]),
Expand Down Expand Up @@ -521,3 +525,72 @@ fn omod_recipes_patch_before_publication_and_hashes_use_decoded_sources() {
.to_string_lossy()
.starts_with(".eidos-install")));
}

#[test]
fn replacing_a_member_keeps_a_folder_that_holds_files_its_receipt_never_saw() {
let temp = Fixture::new();
let inst = eidos_instance::Instance::portable(temp.0.join("instance"));
inst.create().unwrap();
let def = eidos_games::catalog()
.iter()
.find(|g| g.id == "skyrimse")
.unwrap();
let game = eidos_games::DetectedGame {
source: Default::default(),
def,
install_path: temp.0.join("game"),
data_path: temp.0.join("game/Data"),
compatdata: None,
steam_name: "test".into(),
};
fs::create_dir_all(&game.data_path).unwrap();
let member = Mod {
name: "Member".into(),
source: Source {
file_id: Some(1),
mod_id: Some(1),
..Default::default()
},
..Default::default()
};
let archive = archive(&temp.0);
let nexus = eidos_nexus::Nexus::with_bearer("synthetic-no-network");
let mut say = |_: String| {};
let mut hooks = RealHooks {
payload_root: std::path::PathBuf::new(),
allow_runtime_mismatch: false,
nexus: &nexus,
inst: &inst,
game: &game,
game_id: "skyrimse".into(),
say: &mut say,
collection_domain: "skyrimspecialedition".into(),
owner: "test:1".into(),
renamed: vec![],
kept: vec![],
};
let folder = hooks.reserve(&member, None).unwrap();
let installed = hooks.install(&member, &archive, &folder);
assert!(matches!(installed, Installed::Ok(_)), "{installed:?}");
let backup = inst.mods_dir().join(format!("{folder}_backup"));

// An untouched member is replaced outright: nothing is kept.
let installed = hooks.install(&member, &archive, &folder);
assert!(matches!(installed, Installed::Ok(_)), "{installed:?}");
assert!(!backup.exists());
assert!(hooks.kept.is_empty());

// A file the user moved in (Sync to Mods) is not in the receipt.
let mine = inst.mods_dir().join(&folder).join("scripts/mine.pex");
fs::write(&mine, b"my edit").unwrap();
let installed = hooks.install(&member, &archive, &folder);
assert!(matches!(installed, Installed::Ok(_)), "{installed:?}");
assert!(!mine.exists());
assert_eq!(fs::read(backup.join("scripts/mine.pex")).unwrap(), b"my edit");
assert_eq!(hooks.kept, vec![(member.name.clone(), backup)]);

let mut report = eidos_collections::report::Report::default();
hooks.drain_notes(&mut report);
assert_eq!(report.kept.len(), 1);
assert!(report.render().contains("Member_backup"), "{}", report.render());
}
76 changes: 76 additions & 0 deletions crates/eidos-collections/tests/plugin_states.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,79 @@ fn external_store_plugin_activation_uses_actual_wine_prefix() {
);
fs::remove_dir_all(root).unwrap();
}

/// A root-mode game on a never-launched profile, with `A.esp` active and
/// `B.esp` deliberately off in the install-root state, and a collection that
/// switches `A.esp` off.
fn root_mode_fixture(
tag: &str,
id: &str,
data: &str,
files: &[(&str, &str)],
) -> (std::path::PathBuf, eidos_instance::Instance, eidos_games::DetectedGame) {
let root = std::env::temp_dir().join(format!("eidos-collection-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let inst = eidos_instance::Instance::portable(root.join("instance"));
inst.create().unwrap();
let def = eidos_games::catalog().iter().find(|g| g.id == id).unwrap();
let game = eidos_games::DetectedGame {
source: Default::default(),
def,
install_path: root.join("game"),
data_path: root.join("game").join(data),
compatdata: Some(root.join("compatdata")),
steam_name: "synthetic".into(),
};
fs::create_dir_all(&game.data_path).unwrap();
for (name, body) in files {
fs::write(game.install_path.join(name), body).unwrap();
}
let collection = eidos_collections::Collection {
plugins: vec![eidos_collections::manifest::Plugin {
name: "A.esp".into(),
enabled: false,
}],
..Default::default()
};
let mut report = Default::default();
for name in ["A.esp", "B.esp"] {
fs::write(game.data_path.join(name), []).unwrap();
}
eidos_collections::driver::apply_plugin_states(&inst, &game, &collection, &mut report);
(root, inst, game)
}

#[test]
fn morrowind_collection_seeds_the_install_ini_instead_of_writing_a_stub() {
let ini = "[General]\r\nSubtitles=1\r\n[Archives]\r\nArchive 0=Tribunal.bsa\r\n\
[Game Files]\r\nGameFile0=A.esp\r\n";
let (root, inst, game) =
root_mode_fixture("morrowind", "morrowind", "Data Files", &[("Morrowind.ini", ini)]);
let profile_ini =
fs::read_to_string(inst.active().plugins_state_dir().join("Morrowind.ini")).unwrap();
// The real INI's other sections survive, and B.esp stays off as the user left it.
assert!(profile_ini.contains("Archive 0=Tribunal.bsa"), "{profile_ini}");
assert!(profile_ini.contains("[General]"), "{profile_ini}");
assert!(!profile_ini.contains("A.esp") && !profile_ini.contains("B.esp"), "{profile_ini}");
// The install root is the state dir here; the prefix shadow must not touch it.
assert_eq!(fs::read_to_string(game.install_path.join("Morrowind.ini")).unwrap(), ini);
assert!(!game.install_path.join("loadorder.txt").exists());
fs::remove_dir_all(root).unwrap();
}

#[test]
fn oblivion_root_mode_collection_seeds_the_install_root_plugins_txt() {
let ini = "[General]\r\nbUseMyGamesDirectory=0\r\n";
let (root, inst, game) = root_mode_fixture(
"oblivion-root",
"oblivion",
"Data",
&[("Oblivion.ini", ini), ("plugins.txt", "A.esp\r\n")],
);
let list = inst.plugin_list(&game.data_path, game.def.id, None).unwrap();
// B.esp was off in the install-root plugins.txt; founding on the empty
// AppData dir turned it on.
assert!(list.plugins.iter().all(|p| !p.enabled), "{:?}", list.plugins);
assert_eq!(fs::read_to_string(game.install_path.join("plugins.txt")).unwrap(), "A.esp\r\n");
fs::remove_dir_all(root).unwrap();
}
1 change: 1 addition & 0 deletions crates/eidos-collections/tests/recipes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ fn actual_pe_runtime_evidence_gates_the_real_driver_before_member_mutation() {
collection_domain: def.nexus_game.into(),
owner: "test:1".into(),
renamed: vec![],
kept: vec![],
};
c.mods.push(Mod {
name: "Must never be reserved".into(),
Expand Down
Loading
Loading