@@ -31,6 +31,8 @@ use super::writable::{
3131} ;
3232use crate :: enums:: { Error , GameId } ;
3333use 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;
3436use 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+
204282fn 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) ]
234297mod 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