Skip to content

Commit 0dbdf98

Browse files
committed
Support implicitly active BlueprintShips plugins
A Starfield plugin named BlueprintShips-X.esm is implicitly active if X.esm, X.esp or X.esl is active, where X is compared case-insensitively. The plugin doesn't need to have the blueprint flag set for this to happen. See <loot/loot#2180 (comment)> for the scenarios I've tested.
1 parent e227305 commit 0dbdf98

5 files changed

Lines changed: 505 additions & 75 deletions

File tree

src/game_settings.rs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ impl GameSettings {
164164
self.id
165165
}
166166

167+
pub(crate) fn supports_blueprint_ships_plugins(&self) -> bool {
168+
self.id == GameId::Starfield
169+
}
170+
167171
pub fn load_order_method(&self) -> LoadOrderMethod {
168172
match self.id {
169173
GameId::OpenMW => LoadOrderMethod::OpenMW,
@@ -741,10 +745,6 @@ fn implicitly_active_plugins(
741745
// Update.esm is always active, but loads after all other masters if it is not made to load
742746
// earlier (e.g. by listing in plugins.txt or by being a master of another master).
743747
plugin_names.push("Update.esm".to_owned());
744-
} else if game_id == GameId::Starfield {
745-
// BlueprintShips-Starfield.esm is always active but loads after all other plugins if not
746-
// made to load earlier.
747-
plugin_names.push("BlueprintShips-Starfield.esm".to_owned());
748748
}
749749

750750
deduplicate(&mut plugin_names);
@@ -1033,6 +1033,45 @@ mod tests {
10331033
assert_eq!(GameId::Morrowind, settings.id());
10341034
}
10351035

1036+
#[test]
1037+
fn supports_blueprint_ships_plugins_should_be_true_for_starfield_only() {
1038+
let mut settings = game_with_generic_paths(GameId::Morrowind);
1039+
assert!(!settings.supports_blueprint_ships_plugins());
1040+
1041+
settings = game_with_generic_paths(GameId::OpenMW);
1042+
assert!(!settings.supports_blueprint_ships_plugins());
1043+
1044+
settings = game_with_generic_paths(GameId::Oblivion);
1045+
assert!(!settings.supports_blueprint_ships_plugins());
1046+
1047+
settings = game_with_generic_paths(GameId::OblivionRemastered);
1048+
assert!(!settings.supports_blueprint_ships_plugins());
1049+
1050+
settings = game_with_generic_paths(GameId::Skyrim);
1051+
assert!(!settings.supports_blueprint_ships_plugins());
1052+
1053+
settings = game_with_generic_paths(GameId::SkyrimSE);
1054+
assert!(!settings.supports_blueprint_ships_plugins());
1055+
1056+
settings = game_with_generic_paths(GameId::SkyrimVR);
1057+
assert!(!settings.supports_blueprint_ships_plugins());
1058+
1059+
settings = game_with_generic_paths(GameId::Fallout3);
1060+
assert!(!settings.supports_blueprint_ships_plugins());
1061+
1062+
settings = game_with_generic_paths(GameId::FalloutNV);
1063+
assert!(!settings.supports_blueprint_ships_plugins());
1064+
1065+
settings = game_with_generic_paths(GameId::Fallout4);
1066+
assert!(!settings.supports_blueprint_ships_plugins());
1067+
1068+
settings = game_with_generic_paths(GameId::Fallout4VR);
1069+
assert!(!settings.supports_blueprint_ships_plugins());
1070+
1071+
settings = game_with_generic_paths(GameId::Starfield);
1072+
assert!(settings.supports_blueprint_ships_plugins());
1073+
}
1074+
10361075
#[test]
10371076
fn load_order_method_should_be_timestamp_for_tes3_tes4_fo3_and_fonv() {
10381077
let mut settings = game_with_generic_paths(GameId::Morrowind);
@@ -2039,14 +2078,6 @@ mod tests {
20392078
assert!(plugins.contains(&"Update.esm".to_owned()));
20402079
}
20412080

2042-
#[test]
2043-
fn implicitly_active_plugins_should_include_blueprintships_starfield_esm_for_starfield() {
2044-
let settings = game_with_generic_paths(GameId::Starfield);
2045-
let plugins = settings.implicitly_active_plugins();
2046-
2047-
assert!(plugins.contains(&"BlueprintShips-Starfield.esm".to_owned()));
2048-
}
2049-
20502081
#[test]
20512082
fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_games_other_than_fallout_nv(
20522083
) {

src/load_order/asterisk_based.rs

Lines changed: 191 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ use super::writable::{
3131
};
3232
use crate::enums::{Error, GameId};
3333
use crate::game_settings::GameSettings;
34+
use crate::load_order::timestamp_based::save_partial_load_order_using_timestamps;
35+
use crate::load_order::writable::blueprint_ships_base_plugin_name;
3436
use crate::plugin::{trim_dot_ghost, Plugin};
3537

3638
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
@@ -61,8 +63,41 @@ impl AsteriskBasedLoadOrder {
6163
fn ignore_active_plugins_file(&self) -> bool {
6264
// Fallout 4 and Starfield ignore plugins.txt if there are any sTestFile plugins listed in
6365
// the ini files.
64-
ignore_active_plugins_file_fallout4(&self.game_settings)
65-
|| ignore_active_plugins_file_starfield(&self.game_settings)
66+
matches!(
67+
self.game_settings.id(),
68+
GameId::Fallout4 | GameId::Fallout4VR | GameId::Starfield
69+
) && self.game_settings.implicitly_active_plugins().len()
70+
> self.game_settings.early_loading_plugins().len()
71+
}
72+
73+
fn activate_blueprint_ships_plugins(&mut self) -> Result<(), Error> {
74+
let active_basenames: HashSet<UniCase<String>> = self
75+
.plugins()
76+
.iter()
77+
.filter_map(|p| {
78+
if p.is_active() {
79+
p.name()
80+
.get(..p.name().len() - 4)
81+
.map(str::to_owned)
82+
.map(UniCase::new)
83+
} else {
84+
None
85+
}
86+
})
87+
.collect();
88+
89+
for plugin in &mut self.plugins {
90+
if let Some(base_plugin_name) = blueprint_ships_base_plugin_name(plugin.name())
91+
.map(str::to_owned)
92+
.map(UniCase::new)
93+
{
94+
if active_basenames.contains(&base_plugin_name) {
95+
plugin.activate()?;
96+
}
97+
}
98+
}
99+
100+
Ok(())
66101
}
67102
}
68103

@@ -97,6 +132,10 @@ impl WritableLoadOrder for AsteriskBasedLoadOrder {
97132

98133
self.add_implicitly_active_plugins()?;
99134

135+
if self.game_settings.id() == GameId::Starfield {
136+
self.activate_blueprint_ships_plugins()?;
137+
}
138+
100139
hoist_masters(&mut self.plugins)?;
101140

102141
Ok(())
@@ -129,7 +168,26 @@ impl WritableLoadOrder for AsteriskBasedLoadOrder {
129168
// writing to it, but it won't actually have any impact on the load
130169
// order used by the game. In that case, the only way to set the
131170
// load order is to modify plugin timestamps, so do that.
132-
save_load_order_using_timestamps(self)?;
171+
save_load_order_using_timestamps(&mut self.plugins)?;
172+
} else if self.game_settings.id() == GameId::Starfield {
173+
// Blueprint plugins and BlueprintShips plugins get removed from
174+
// plugins.txt by Starfield after the file is read. However,
175+
// BlueprintShips plugins are still implicitly active if the plugin
176+
// referenced by their filename suffix is active, so their load
177+
// order is relatively important.
178+
// Blueprint masters get loaded after all other plugins, and if not
179+
// explicitly active they get loaded in timestamp order, so set
180+
// their timestamps to reflect their load order, so that they'll
181+
// load in the intended order even after Starfield strips them from
182+
// plugins.txt.
183+
// This doesn't help with non-master blueprint plugins, or with
184+
// BlueprintShips plugins that are not blueprint plugins, but all
185+
// official BlueprintShips plugins (as of 2026-03-26) are blueprint
186+
// masters, and there are no other official blueprint plugins.
187+
// I don't know how common blueprint plugins are in mods.
188+
let blueprint_masters_iter =
189+
self.plugins.iter_mut().filter(|p| p.is_blueprint_master());
190+
save_partial_load_order_using_timestamps(blueprint_masters_iter)?;
133191
}
134192

135193
Ok(())
@@ -179,10 +237,26 @@ impl WritableLoadOrder for AsteriskBasedLoadOrder {
179237
// Plugins that are active but not implicitly active, and plugins that
180238
// are inactive, only have a load order position if they're listed in
181239
// plugins.txt, so check that they're all listed.
240+
// Starfield removes blueprint plugins from plugins.txt, which means
241+
// inactive blueprint plugins' positions become ambiguous after each
242+
// game session, but resolving that ambiguity will just be undone the
243+
// next time the game is loaded, so there's not really any point
244+
// reporting it.
245+
// Starfield will also load plugins named BlueprintShips-<X>.esm for any
246+
// active plugins with the basename <X> (e.g. <X>.esp, <X>.esm,
247+
// <X>.esl), even if the BlueprintShips plugin is not a blueprint
248+
// plugin and/or not listed in plugins.txt. Like blueprint plugins,
249+
// BlueprintShips plugins are removed from plugins.txt whether they're
250+
// active or not, so also skip them.
182251
let plugins_listed = self
183252
.plugins
184253
.iter()
185-
.filter(|plugin| !self.game_settings.is_implicitly_active(plugin.name()))
254+
.filter(|plugin| {
255+
!(self.game_settings.is_implicitly_active(plugin.name())
256+
|| plugin.is_blueprint_plugin()
257+
|| (self.game_settings.supports_blueprint_ships_plugins()
258+
&& is_blueprint_ships_plugin(plugin.name())))
259+
})
186260
.all(|plugin| set.contains(&UniCase::new(plugin.name().to_owned())));
187261

188262
Ok(!plugins_listed)
@@ -201,6 +275,10 @@ impl WritableLoadOrder for AsteriskBasedLoadOrder {
201275
}
202276
}
203277

278+
fn is_blueprint_ships_plugin(plugin_name: &str) -> bool {
279+
blueprint_ships_base_plugin_name(plugin_name).is_some()
280+
}
281+
204282
fn plugin_line_mapper(line: &str) -> Option<(&str, bool)> {
205283
if line.is_empty() || line.starts_with('#') {
206284
None
@@ -215,21 +293,6 @@ fn owning_plugin_line_mapper(line: &str) -> Option<(String, bool)> {
215293
plugin_line_mapper(line).map(|(name, active)| (name.to_owned(), active))
216294
}
217295

218-
fn ignore_active_plugins_file_fallout4(game_settings: &GameSettings) -> bool {
219-
// The implicitly active plugins are the early loading plugins plus test file plugins.
220-
matches!(game_settings.id(), GameId::Fallout4 | GameId::Fallout4VR)
221-
&& game_settings.implicitly_active_plugins().len()
222-
> game_settings.early_loading_plugins().len()
223-
}
224-
225-
fn ignore_active_plugins_file_starfield(game_settings: &GameSettings) -> bool {
226-
// The implicitly active plugins are the early loading plugins plus test file plugins plus
227-
// BlueprintShips-Starfield.esm.
228-
game_settings.id() == GameId::Starfield
229-
&& game_settings.implicitly_active_plugins().len()
230-
> game_settings.early_loading_plugins().len() + 1
231-
}
232-
233296
#[cfg(test)]
234297
mod tests {
235298
use super::*;
@@ -264,6 +327,16 @@ mod tests {
264327
text.lines().map(std::borrow::ToOwned::to_owned).collect()
265328
}
266329

330+
fn copy_as_blueprint_plugin(settings: &GameSettings, plugin_name: &str) {
331+
copy_to_test_dir("Blank.full.esm", plugin_name, settings);
332+
set_blueprint_flag(
333+
settings.id(),
334+
&settings.plugins_directory().join(plugin_name),
335+
true,
336+
)
337+
.unwrap();
338+
}
339+
267340
#[test]
268341
fn ignore_active_plugins_file_should_be_true_for_fallout4_when_test_files_are_configured() {
269342
let tmp_dir = tempdir().unwrap();
@@ -731,6 +804,39 @@ mod tests {
731804
assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
732805
}
733806

807+
#[test]
808+
fn load_should_activate_blueprint_ships_plugins_for_active_starfield_plugins() {
809+
let tmp_dir = tempdir().unwrap();
810+
let mut load_order = prepare(GameId::Starfield, tmp_dir.path());
811+
812+
let filenames = &[
813+
"starfield.esm",
814+
"BlueprintShips-Starfield.esm",
815+
"A.esm",
816+
"BlueprintShips-a.esm",
817+
"BlueprintShips-B.esm",
818+
"BlueprintShips-Blank.esm",
819+
];
820+
821+
for filename in filenames {
822+
copy_to_test_dir("Blank.full.esm", filename, load_order.game_settings());
823+
}
824+
825+
write_active_plugins_file(load_order.game_settings(), &["A.esm"]);
826+
827+
load_order.load().unwrap();
828+
829+
assert_eq!(
830+
&[
831+
"starfield.esm",
832+
"A.esm",
833+
"BlueprintShips-Starfield.esm",
834+
"BlueprintShips-a.esm"
835+
],
836+
load_order.active_plugin_names().as_slice()
837+
);
838+
}
839+
734840
#[test]
735841
fn save_should_create_active_plugins_file_parent_directory_if_it_does_not_exist() {
736842
let tmp_dir = tempdir().unwrap();
@@ -914,6 +1020,48 @@ mod tests {
9141020
assert_eq!(original_timestamp, new_timestamp);
9151021
}
9161022

1023+
#[test]
1024+
fn save_should_update_blueprint_master_timestamps_to_reflect_load_order() {
1025+
let tmp_dir = tempdir().unwrap();
1026+
1027+
let mut load_order = prepare(GameId::Starfield, tmp_dir.path());
1028+
1029+
let plugin_name1 = "Blueprint1.esp";
1030+
let plugin_name2 = "Blueprint2.esp";
1031+
copy_as_blueprint_plugin(&load_order.game_settings, plugin_name1);
1032+
copy_as_blueprint_plugin(&load_order.game_settings, plugin_name2);
1033+
1034+
let plugin_path1 = load_order
1035+
.game_settings
1036+
.plugins_directory()
1037+
.join(plugin_name1);
1038+
let plugin_path2 = load_order
1039+
.game_settings
1040+
.plugins_directory()
1041+
.join(plugin_name2);
1042+
1043+
let first_timestamp = plugin_path1.metadata().unwrap().modified().unwrap();
1044+
File::options()
1045+
.write(true)
1046+
.open(&plugin_path2)
1047+
.unwrap()
1048+
.set_modified(first_timestamp + Duration::from_secs(1))
1049+
.unwrap();
1050+
1051+
load_order.load().unwrap();
1052+
1053+
let last_index = load_order.plugins.len() - 1;
1054+
load_order.plugins.swap(last_index - 1, last_index);
1055+
1056+
load_order.save().unwrap();
1057+
1058+
let plugin_timestamp1 = plugin_path1.metadata().unwrap().modified().unwrap();
1059+
let plugin_timestamp2 = plugin_path2.metadata().unwrap().modified().unwrap();
1060+
1061+
assert_eq!(first_timestamp, plugin_timestamp2);
1062+
assert_eq!(first_timestamp + Duration::from_secs(1), plugin_timestamp1);
1063+
}
1064+
9171065
#[test]
9181066
fn is_self_consistent_should_return_true() {
9191067
let tmp_dir = tempdir().unwrap();
@@ -957,7 +1105,28 @@ mod tests {
9571105
}
9581106

9591107
#[test]
960-
fn is_ambiguous_should_ignore_loaded_implicitly_active_plugins() {
1108+
fn is_ambiguous_should_ignore_blueprint_plugins() {
1109+
let tmp_dir = tempdir().unwrap();
1110+
let mut load_order = prepare(GameId::Starfield, tmp_dir.path());
1111+
1112+
let loaded_plugin_names: Vec<&str> = load_order
1113+
.plugins
1114+
.iter()
1115+
.map(crate::plugin::Plugin::name)
1116+
.collect();
1117+
1118+
write_active_plugins_file(load_order.game_settings(), &loaded_plugin_names);
1119+
1120+
let blueprint_plugin_name = "Blueprint.esp";
1121+
copy_as_blueprint_plugin(&load_order.game_settings, blueprint_plugin_name);
1122+
let plugin = Plugin::new(blueprint_plugin_name, load_order.game_settings()).unwrap();
1123+
load_order.plugins_mut().push(plugin);
1124+
1125+
assert!(!load_order.is_ambiguous().unwrap());
1126+
}
1127+
1128+
#[test]
1129+
fn is_ambiguous_should_ignore_blueprint_ships_plugins() {
9611130
let tmp_dir = tempdir().unwrap();
9621131
let mut load_order = prepare(GameId::Starfield, tmp_dir.path());
9631132

@@ -971,11 +1140,10 @@ mod tests {
9711140

9721141
copy_to_test_dir(
9731142
"Blank.full.esm",
974-
"BlueprintShips-Starfield.esm",
1143+
"BlueprintShips-Blank.esm",
9751144
load_order.game_settings(),
9761145
);
977-
let plugin =
978-
Plugin::new("BlueprintShips-Starfield.esm", load_order.game_settings()).unwrap();
1146+
let plugin = Plugin::new("BlueprintShips-Blank.esm", load_order.game_settings()).unwrap();
9791147
load_order.plugins_mut().push(plugin);
9801148

9811149
assert!(!load_order.is_ambiguous().unwrap());

0 commit comments

Comments
 (0)