• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 28816127817

06 Jul 2026 07:02PM UTC coverage: 87.609% (-0.2%) from 87.84%
28816127817

Pull #4688

github

web-flow
Merge 40b7b0feb into 0daeea8d7
Pull Request #4688: [Feature]: Support for Kitten Space Agency

2093 of 2226 branches covered (94.03%)

Branch coverage included in aggregate %.

289 of 368 new or added lines in 13 files covered. (78.53%)

8908 of 10331 relevant lines covered (86.23%)

1.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.44
/Core/GameInstanceManager.cs
1
using System;
2
using System.Collections.Generic;
3
using System.IO;
4
using System.Linq;
5
using System.Transactions;
6
using System.Diagnostics.CodeAnalysis;
7

8
using Autofac;
9
using ChinhDo.Transactions;
10
using log4net;
11

12
using CKAN.IO;
13
using CKAN.Versioning;
14
using CKAN.Configuration;
15
using CKAN.Games;
16
using CKAN.Games.KerbalSpaceProgram;
17
using CKAN.Games.KerbalSpaceProgram.GameVersionProviders;
18
using CKAN.Games.KittenSpaceAgency;
19
using CKAN.DLC;
20

21
namespace CKAN
22
{
23
    /// <summary>
24
    /// Manage multiple game installs.
25
    /// </summary>
26
    public class GameInstanceManager : IDisposable
27
    {
28
        /// <summary>
29
        /// An IUser object for user interaction.
30
        /// It is initialized during the startup with a ConsoleUser,
31
        /// do not use in functions that could be called by the GUI.
32
        /// </summary>
33
        public IUser          User            { get; set; }
34
        public IConfiguration Configuration   { get; set; }
35
        public GameInstance?  CurrentInstance { get; private set; }
36
        public event Action<GameInstance?, GameInstance?>? InstanceChanged;
37

38
        public NetModuleCache? Cache { get; private set; }
39
        public event Action<NetModuleCache>? CacheChanged;
40

41
        public  SteamLibrary  SteamLibrary => steamLib ??= new SteamLibrary();
2✔
42
        private SteamLibrary? steamLib;
43

44
        private static readonly ILog log = LogManager.GetLogger(typeof (GameInstanceManager));
2✔
45

46
        private readonly SortedList<string, GameInstance> instances = new SortedList<string, GameInstance>();
2✔
47

48
        public SortedList<string, GameInstance> Instances => new SortedList<string, GameInstance>(instances);
2✔
49

50
        public GameInstanceManager(IUser          user,
2✔
51
                                   IConfiguration configuration,
52
                                   SteamLibrary?  steamLib = null)
53
        {
54
            User = user;
2✔
55
            Configuration = configuration;
2✔
56
            this.steamLib = steamLib;
2✔
57
            LoadInstances();
2✔
58
            LoadCacheSettings();
2✔
59
        }
2✔
60

61
        /// <summary>
62
        /// Returns the preferred game instance, or null if none can be found.
63
        ///
64
        /// This works by checking to see if we're in a game dir first, then the
65
        /// config for an autostart instance, then will try to auto-populate
66
        /// by scanning for the game.
67
        ///
68
        /// This *will not* touch the config if we find a portable install.
69
        ///
70
        /// This *will* run game instance autodetection if the config is empty.
71
        ///
72
        /// This *will* set the current instance, or throw an exception if it's already set.
73
        ///
74
        /// Returns null if we have multiple instances, but none of them are preferred.
75
        /// </summary>
76
        public GameInstance? GetPreferredInstance()
77
            => CurrentInstance ??= _GetPreferredInstance();
2✔
78

79
        private GameInstance? _GetPreferredInstance()
80
        {
81
            // First check if we're part of a portable install
82
            // Note that this *does not* register in the config.
83
            switch (KnownGames.knownGames.Select(g => GameInstance.PortableDir(g)
2✔
84
                                                      is string p
85
                                                          ? new GameInstance(g, p,
86
                                                                             Properties.Resources.GameInstanceManagerPortable)
87
                                                          : null)
88
                                         .OfType<GameInstance>()
89
                                         .Where(i => i.Valid)
×
90
                                         .ToArray())
91
            {
92
                case { Length: 1 } insts:
93
                    return insts.Single();
×
94

95
                case { Length: > 1 } insts:
96
                    if (User.RaiseSelectionDialog(
×
97
                            string.Format(Properties.Resources.GameInstanceManagerSelectGamePrompt,
98
                                          string.Join(", ", insts.Select(i => i.GameDir)
×
99
                                                                 .Distinct()
100
                                                                 .Select(Platform.FormatPath))),
101
                            insts.Select(i => i.Game.ShortName)
×
102
                                 .ToArray())
103
                        is int selection and >= 0)
104
                    {
105
                        return insts[selection];
×
106
                    }
107
                    break;
108
            }
109

110
            // If we only know of a single instance, return that.
111
            if (instances.Count == 1
2✔
112
                && instances.Values.Single() is { Valid: true } and var inst)
113
            {
114
                return inst;
2✔
115
            }
116

117
            // Return the autostart, if we can find it.
118
            // We check both null and "" as we can't write NULL to the config, so we write an empty string instead
119
            // This is necessary so we can indicate that the user wants to reset the current AutoStartInstance without clearing the config!
120
            if (Configuration.AutoStartInstance is { Length: > 0 } instName
2✔
121
                && instances.TryGetValue(instName, out GameInstance? autoInst)
122
                && autoInst.Valid)
123
            {
124
                return autoInst;
×
125
            }
126

127
            // If we know of no instances, try to find one.
128
            // Otherwise, we know of too many instances!
129
            // We don't know which one to pick, so we return null.
130
            return instances.Count == 0 ? FindAndRegisterDefaultInstances() : null;
2✔
131
        }
132

133
        /// <summary>
134
        /// Find and register default instances by running
135
        /// game autodetection code. Registers one per known game,
136
        /// uses first found as default.
137
        ///
138
        /// Returns the resulting game instance if found.
139
        /// </summary>
140
        public GameInstance? FindAndRegisterDefaultInstances()
141
        {
142
            if (instances.Count != 0)
2✔
143
            {
144
                throw new GameManagerKraken("Attempted to scan for defaults with instances");
×
145
            }
146
            var found = FindDefaultInstances();
2✔
147
            foreach (var inst in found)
6✔
148
            {
149
                log.DebugFormat("Registering {0} at {1}...",
2✔
150
                                inst.Name, inst.GameDir);
151
                AddInstance(inst);
2✔
152
            }
153
            return found.FirstOrDefault();
2✔
154
        }
155

156
        public GameInstance[] FindDefaultInstances()
157
        {
158
            var found = KnownGames.knownGames.SelectMany(g =>
2✔
159
                            SteamLibrary.Games
2✔
160
                                        .Select(sg => sg.Name is string name && sg.GameDir is DirectoryInfo dir
2✔
161
                                                          ? new Tuple<string, DirectoryInfo>(name, dir)
162
                                                          : null)
163
                                        .Append(g.MacPath() is DirectoryInfo dir
164
                                                    ? new Tuple<string, DirectoryInfo>(
165
                                                        string.Format(Properties.Resources.GameInstanceManagerAuto,
166
                                                                      g.ShortName),
167
                                                        dir)
168
                                                    : null)
169
                                        .OfType<Tuple<string, DirectoryInfo>>()
170
                                        .Select(tuple => tuple.Item1 != null && g.GameInFolder(tuple.Item2)
2✔
171
                                                       ? new GameInstance(g, tuple.Item2.FullName,
172
                                                                          tuple.Item1 ?? g.ShortName)
173
                                                       : null)
174
                                        .OfType<GameInstance>())
175
                                  .Where(inst => inst.Valid)
2✔
176
                                  .ToArray();
177
            foreach (var group in found.GroupBy(inst => inst.Name))
2✔
178
            {
179
                if (group.Count() > 1)
2✔
180
                {
181
                    // Make sure the names are unique
182
                    int index = 0;
×
183
                    foreach (var inst in group)
×
184
                    {
185
                        // Find an unused name
186
                        string name;
187
                        do
188
                        {
189
                            ++index;
×
190
                            name = $"{group.Key} ({++index})";
×
191
                        }
192
                        while (found.Any(other => other.Name == name));
×
193
                        inst.Name = name;
×
194
                    }
195
                }
196
            }
197
            return found;
2✔
198
        }
199

200
        /// <summary>
201
        /// Adds a game instance to config.
202
        /// Warns if the instance shares its external mod directory with an
203
        /// already registered instance, because CKAN mod operations in one of
204
        /// them affect the others (each instance keeps its own registry).
205
        /// </summary>
206
        /// <param name="instance">The instance to register</param>
207
        /// <param name="user">IUser to receive the warning; falls back to the manager's User</param>
208
        /// <param name="warnSharedModRoot">false to skip the shared-mod-root warning: for re-registering an instance that was registered before (restoring after a failed path edit), or when the user already confirmed it</param>
209
        /// <returns>The resulting GameInstance object</returns>
210
        /// <exception cref="NotGameDirKraken">Thrown if the instance is not a valid game instance.</exception>
211
        public GameInstance AddInstance(GameInstance instance, IUser? user = null, bool warnSharedModRoot = true)
212
        {
213
            if (instance.Valid)
2✔
214
            {
215
                var sharers = InstancesSharingModRoot(instance);
2✔
216
                string name = instance.Name;
2✔
217
                instances.Add(name, instance);
2✔
218
                Configuration.SetRegistryToInstances(instances);
2✔
219
                if (sharers.Length > 0)
2✔
220
                {
221
                    log.WarnFormat("Instance {0} shares its mod directory with: {1}",
2✔
222
                                   name, string.Join(", ", sharers.Select(other => other.Name)));
2✔
223
                    if (warnSharedModRoot)
2✔
224
                    {
225
                        (user ?? User).RaiseError("{0}", SharedModRootWarning(instance, sharers));
2✔
226
                    }
227
                }
228
            }
229
            else
230
            {
231
                throw new NotGameDirKraken(instance.GameDir);
×
232
            }
233
            return instance;
2✔
234
        }
235

236
        // The rendered shared-mod-root warning, shared by the warning and
237
        // confirmation paths so both always tell the same story.
238
        private static string SharedModRootWarning(GameInstance instance, GameInstance[] sharers)
239
            => string.Format(Properties.Resources.GameInstanceManagerSharedModRootWarning,
2✔
240
                             instance.Name,
241
                             string.Join(", ", sharers.Select(other => other.Name)),
2✔
242
                             instance.Game.ShortName);
243

244
        // A concrete relocation suggestion for the unwritable-game-folder error
245
        // that never needs admin rights: a Games folder in the user's profile,
246
        // keeping the game's current folder name. The game's short name stands
247
        // in when the game sits at a filesystem root, which has no usable
248
        // folder name (its rooted "name" would even reset Path.Combine back to
249
        // the root itself); "~" stands in on the rare profile-less
250
        // environments where UserProfile resolves to empty.
251
        internal static string SuggestedRelocationPath(IGame game, string normalizedGameDir)
252
        {
253
            var dirInfo = new DirectoryInfo(normalizedGameDir);
2✔
254
            var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
2✔
255
            return Platform.FormatPath(Path.Combine(
2✔
256
                string.IsNullOrEmpty(profile) ? "~" : profile,
257
                "Games",
258
                dirInfo.Parent == null ? game.ShortName : dirInfo.Name));
259
        }
260

261
        /// <summary>
262
        /// Find already registered instances that use the same external mod
263
        /// directory as the given instance.
264
        /// A game that stores mods outside the game folder (KSA) has one mod
265
        /// directory and one mod list per user, shared by every instance of
266
        /// that game, while CKAN tracks the files it installs per instance.
267
        /// </summary>
268
        /// <param name="instance">The instance to compare against the registered ones</param>
269
        /// <returns>The other registered instances sharing this instance's mod directory</returns>
270
        internal GameInstance[] InstancesSharingModRoot(GameInstance instance)
271
            => instance.Game.ModDirectoryIsExternal
2✔
272
                   ? instances.Values
273
                              .Where(other => !ReferenceEquals(other, instance)
2✔
274
                                              && other.Game.ModDirectoryIsExternal
275
                                              && string.Equals(CKANPathUtils.NormalizePath(other.Game.PrimaryModDirectory(other)),
276
                                                               CKANPathUtils.NormalizePath(instance.Game.PrimaryModDirectory(instance)),
277
                                                               Platform.PathComparison))
278
                              .ToArray()
279
                   : Array.Empty<GameInstance>();
280

281
        /// <summary>
282
        /// Adds a game instance to config.
283
        /// This is the interactive front door (GUI add dialog, CmdLine
284
        /// `instance add`, ConsoleUI screens): when the instance would share
285
        /// its external mod directory with an already registered one, the user
286
        /// is asked to confirm and can decline, unlike the non-interactive
287
        /// paths (Steam import, clone, fake, autodetection), which register
288
        /// with a warning.
289
        /// A game folder CKAN cannot write to (its per-instance state lives in
290
        /// GameDir/CKAN) is reported with an actionable error instead of the
291
        /// raw exception, and nothing is registered.
292
        /// </summary>
293
        /// <param name="path">The path of the instance</param>
294
        /// <param name="name">The name of the instance</param>
295
        /// <param name="user">IUser object for interaction</param>
296
        /// <returns>The resulting GameInstance object, or null if nothing was registered (the user declined a confirmation or cancelled a dialog, or the game folder was not writable)</returns>
297
        /// <exception cref="NotGameDirKraken">Thrown if the instance is not a valid game instance.</exception>
298
        public GameInstance? AddInstance(string path, string name, IUser user)
299
        {
300
            var game = DetermineGame(new DirectoryInfo(path), user);
2✔
301
            if (game == null)
2✔
302
            {
NEW
303
                return null;
×
304
            }
305
            GameInstance instance;
306
            try
307
            {
308
                instance = new GameInstance(game, path, name);
2✔
309
            }
2✔
310
            catch (Exception exc) when (exc is UnauthorizedAccessException or IOException)
2✔
311
            {
312
                // Constructing the instance sets up CKAN's state folder inside
313
                // the game folder. Nothing has been registered at this point,
314
                // so report the failure instead of surfacing the raw exception.
315
                // Access denied means the game is installed somewhere the user
316
                // cannot write, like KSA's default install location under
317
                // Program Files, and gets the full relocation guidance; any
318
                // other IO error (a file squatting on the CKAN name, a
319
                // transient lock) gets the underlying message, which already
320
                // names its own cause.
321
                log.Warn("Could not set up the CKAN state folder", exc);
2✔
322
                // The same resolution the constructor applies, so the message
323
                // shows the exact path the instance would have used.
324
                var gameDir = GameInstance.NormalizeGameDir(path);
2✔
325
                var ckanDir = Platform.FormatPath(Path.Combine(gameDir, "CKAN"));
2✔
326
                user.RaiseError("{0}", exc is UnauthorizedAccessException
2✔
327
                    ? string.Format(Properties.Resources.GameInstanceManagerStateDirNotWritable,
328
                                    ckanDir, exc.Message,
329
                                    SuggestedRelocationPath(game, gameDir))
330
                    : string.Format(Properties.Resources.GameInstanceManagerStateDirCreationFailed,
331
                                    ckanDir, exc.Message));
332
                return null;
2✔
333
            }
334
            if (instance.Valid
2✔
335
                && InstancesSharingModRoot(instance) is { Length: > 0 } sharers
336
                && !user.RaiseYesNoDialog(SharedModRootWarning(instance, sharers)
337
                                          + Environment.NewLine
338
                                          + Properties.Resources.GameInstanceManagerSharedModRootPrompt))
339
            {
340
                return null;
2✔
341
            }
342
            // The user already confirmed, don't warn again
343
            return AddInstance(instance, user, warnSharedModRoot: false);
2✔
344
        }
2✔
345

346
        /// <summary>
347
        /// Clones an existing game installation.
348
        /// </summary>
349
        /// <param name="existingInstance">The game instance to clone.</param>
350
        /// <param name="newName">The name for the new instance.</param>
351
        /// <param name="newPath">The path where the new instance should be located.</param>
352
        /// <param name="shareStockFolders">True to make junctions or symlinks to stock folders instead of copying</param>
353
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
354
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if something went wrong with copying.</exception>
355
        /// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
356
        /// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
357
        /// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
358
        public GameInstance CloneInstance(GameInstance existingInstance,
359
                                          string       newName,
360
                                          string       newPath,
361
                                          bool         shareStockFolders = false)
362
            => CloneInstance(existingInstance, newName, newPath,
2✔
363
                             existingInstance.Game.LeaveEmptyInClones,
364
                             shareStockFolders);
365

366
        /// <summary>
367
        /// Clones an existing game installation.
368
        /// </summary>
369
        /// <param name="existingInstance">The game instance to clone.</param>
370
        /// <param name="newName">The name for the new instance.</param>
371
        /// <param name="newPath">The path where the new instance should be located.</param>
372
        /// <param name="leaveEmpty">Dirs whose contents should not be copied</param>
373
        /// <param name="shareStockFolders">True to make junctions or symlinks to stock folders instead of copying</param>
374
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
375
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if something went wrong with copying.</exception>
376
        /// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
377
        /// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
378
        /// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
379
        public GameInstance CloneInstance(GameInstance existingInstance,
380
                                          string       newName,
381
                                          string       newPath,
382
                                          string[]     leaveEmpty,
383
                                          bool         shareStockFolders = false)
384
        {
385
            if (HasInstance(newName))
2✔
386
            {
387
                throw new InstanceNameTakenKraken(newName);
2✔
388
            }
389
            if (!existingInstance.Valid)
2✔
390
            {
391
                throw new NotGameDirKraken(existingInstance.GameDir, string.Format(
2✔
392
                    Properties.Resources.GameInstanceCloneInvalid, existingInstance.Game.ShortName));
393
            }
394

395
            CKANPathUtils.CheckFreeSpace(new DirectoryInfo(newPath) switch
2✔
396
                                         {
397
                                             { Exists: true } di => di,
2✔
398
                                             var di              => di.Parent ?? di.Root,
2✔
399
                                         },
400
                                         HardLink.GetDeviceIdentifiers(existingInstance.GameDir,
401
                                                                       newPath)
402
                                                 .Distinct()
403
                                                 .Count() > 1
404
                                             ? existingInstance.TotalSize
405
                                             : existingInstance.NonHardLinkableSize(leaveEmpty),
406
                                         Properties.Resources.GameInstanceManagerCloneNotEnoughFreeSpace);
407

408
            log.Debug("Copying directory.");
2✔
409
            Utilities.CopyDirectory(existingInstance.GameDir, newPath,
2✔
410
                                    new string[] { "CKAN/registry.locked", "CKAN/playtime.json", "CKAN/GUIConfig.json" },
411
                                    shareStockFolders ? existingInstance.Game.StockFolders
412
                                                      : Array.Empty<string>(),
413
                                    leaveEmpty,
414
                                    new string[] { "CKAN" });
415

416
            // Add the new instance to the config
417
            return AddInstance(new GameInstance(existingInstance.Game, newPath, newName));
2✔
418
        }
419

420
        /// <summary>
421
        /// Create a new fake game instance
422
        /// </summary>
423
        /// <param name="game">The game of the new instance.</param>
424
        /// <param name="newName">The name for the new instance.</param>
425
        /// <param name="newPath">The location of the new instance.</param>
426
        /// <param name="version">The version of the new instance. Should have a build number.</param>
427
        /// <param name="dlcs">The IDlcDetector implementations for the DLCs that should be faked and the requested dlc version as a dictionary.</param>
428
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
429
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if a write operation didn't complete for whatever reason.</exception>
430
        public GameInstance FakeInstance(IGame game, string newName, string newPath, GameVersion version,
431
                                         Dictionary<IDlcDetector, GameVersion>? dlcs = null)
432
        {
433
            var txFileMgr = new TxFileManager();
2✔
434
            using (TransactionScope transaction = CkanTransaction.CreateTransactionScope())
2✔
435
            {
436
                if (HasInstance(newName))
2✔
437
                {
438
                    throw new InstanceNameTakenKraken(newName);
2✔
439
                }
440

441
                // KSA's build counter (the 3rd version component) is pinned to 0 on
442
                // every version CKAN stores (see KittenSpaceAgency.NormalizeBuildCounter),
443
                // so normalize the requested version the same way before the build-map
444
                // check below, which compares that component exactly. This lets callers
445
                // pass the raw in-game version string (e.g. 2026.6.9.4750).
446
                if (game is KittenSpaceAgency)
2✔
447
                {
448
                    version = KittenSpaceAgency.NormalizeBuildCounter(version);
2✔
449
                }
450

451
                // For KSA the 4th component (the revision) is the real game version
452
                // ordinal, so it must itself match a known version; checking
453
                // WithoutBuild would degenerate the gate to year.month and accept
454
                // revisions that never shipped. For the other games the 4th
455
                // component is build metadata and stays out of the check.
456
                if (!(game is KittenSpaceAgency ? version.InBuildMap(game)
2✔
457
                                                : version.WithoutBuild.InBuildMap(game)))
458
                {
459
                    throw new BadGameVersionKraken(string.Format(
2✔
460
                        Properties.Resources.GameInstanceFakeBadVersion, game.ShortName, version));
461
                }
462
                if (Directory.Exists(newPath) && (Directory.GetFiles(newPath).Length != 0 || Directory.GetDirectories(newPath).Length != 0))
2✔
463
                {
464
                    throw new BadInstallLocationKraken(Properties.Resources.GameInstanceFakeNotEmpty);
2✔
465
                }
466

467
                log.DebugFormat("Creating folder structure and text files at {0} for {1} version {2}", Path.GetFullPath(newPath), game.ShortName, version.ToString());
2✔
468

469
                // Create a game root directory, containing a GameData folder, a buildID.txt/buildID64.txt and a readme.txt
470
                txFileMgr.CreateDirectory(newPath);
2✔
471
                // For games whose mod directory lives outside GameDir (KSA), the
472
                // relative "mods" prefix is not a real GameDir subfolder, so don't
473
                // create a stray empty one the game never reads.
474
                if (!game.ModDirectoryIsExternal)
2✔
475
                {
476
                    txFileMgr.CreateDirectory(Path.Combine(newPath, game.PrimaryModDirectoryRelative));
2✔
477
                }
478
                game.RebuildSubdirectories(newPath);
2✔
479

480
                foreach (var anchor in game.InstanceAnchorFiles)
6✔
481
                {
482
                    txFileMgr.WriteAllBytes(Path.Combine(newPath, anchor),
2✔
483
                                            Encoding.UTF8.GetBytes(version.WithoutBuild.ToString() ?? ""));
484
                }
485

486
                // Don't write the buildID.txts if we have no build, otherwise it would be -1.
487
                if (version.IsBuildDefined && game is KerbalSpaceProgram)
2✔
488
                {
489
                    foreach (var b in KspBuildIdVersionProvider.buildIDfilenames)
6✔
490
                    {
491
                        txFileMgr.WriteAllBytes(Path.Combine(newPath, b),
2✔
492
                                                Encoding.UTF8.GetBytes(string.Format("build id = {0}", version.Build)));
493
                    }
494
                }
495

496
                // A fake KSA instance needs a Content/Versions build file, because that
497
                // is the only on-disk version source KsaBuildVersionProvider reads (KSP2
498
                // falls back to its known-versions list, KSA does not), and an instance
499
                // without a detectable version is rejected by AddInstance as invalid.
500
                // The file carries the normalized version (build counter pinned to 0),
501
                // which is what DetectVersion would reduce a raw value to anyway.
502
                if (game is KittenSpaceAgency)
2✔
503
                {
504
                    var versionsDir = Path.Combine(newPath, KsaBuildVersionProvider.versionsDirRelative);
2✔
505
                    txFileMgr.CreateDirectory(versionsDir);
2✔
506
                    txFileMgr.WriteAllBytes(
2✔
507
                        Path.Combine(versionsDir, KsaBuildVersionProvider.VersionFileName(version)),
508
                        Encoding.UTF8.GetBytes(KsaBuildVersionProvider.VersionFileContents(version)));
509
                }
510

511
                // Create the readme.txt WITHOUT build number
512
                txFileMgr.WriteAllBytes(Path.Combine(newPath, "readme.txt"),
2✔
513
                                        Encoding.UTF8.GetBytes(string.Format("Version {0}",
514
                                                     version.WithoutBuild.ToString())));
515

516
                // Create the needed folder structure and the readme.txt for DLCs that should be simulated.
517
                if (dlcs != null)
2✔
518
                {
519
                    foreach ((IDlcDetector dlcDetector, GameVersion dlcVersion) in dlcs)
6✔
520
                    {
521
                        if (!dlcDetector.AllowedOnBaseVersion(version))
2✔
522
                        {
523
                            throw new WrongGameVersionKraken(
2✔
524
                                version,
525
                                string.Format(Properties.Resources.GameInstanceFakeDLCNotAllowed,
526
                                    game.ShortName,
527
                                    dlcDetector.ReleaseGameVersion,
528
                                    dlcDetector.IdentifierBaseName));
529
                        }
530

531
                        string dlcDir = Path.Combine(newPath, dlcDetector.InstallPath());
2✔
532
                        txFileMgr.CreateDirectory(dlcDir);
2✔
533
                        txFileMgr.WriteAllBytes(
2✔
534
                            Path.Combine(dlcDir, "readme.txt"),
535
                            Encoding.UTF8.GetBytes(string.Format("Version {0}", dlcVersion)));
536
                    }
537
                }
538

539
                // Add the new instance to the config
540
                GameInstance new_instance = new GameInstance(game, newPath, newName);
2✔
541
                AddInstance(new_instance);
2✔
542
                transaction.Complete();
2✔
543
                return new_instance;
2✔
544
            }
545
        }
2✔
546

547
        /// <summary>
548
        /// Given a string returns a unused valid instance name by postfixing the string
549
        /// </summary>
550
        /// <returns> A unused valid instance name.</returns>
551
        /// <param name="name">The name to use as a base.</param>
552
        /// <exception cref="Kraken">Could not find a valid name.</exception>
553
        public string GetNextValidInstanceName(string name)
554
        {
555
            // Check if the current name is valid
556
            if (InstanceNameIsValid(name))
2✔
557
            {
558
                return name;
×
559
            }
560

561
            // Try appending a number to the name
562
            var validName = Enumerable.Repeat(name, 1000)
2✔
563
                .Select((s, i) => s + " (" + i + ")")
2✔
564
                .FirstOrDefault(InstanceNameIsValid);
565
            if (validName != null)
2✔
566
            {
567
                return validName;
2✔
568
            }
569

570
            // Check if a name with the current timestamp is valid
571
            validName = name + " (" + DateTime.Now + ")";
×
572

573
            if (InstanceNameIsValid(validName))
×
574
            {
575
                return validName;
×
576
            }
577

578
            // Give up
579
            throw new Kraken(Properties.Resources.GameInstanceNoValidName);
×
580
        }
581

582
        /// <summary>
583
        /// Check if the instance name is valid.
584
        /// </summary>
585
        /// <returns><c>true</c>, if name is valid, <c>false</c> otherwise.</returns>
586
        /// <param name="name">Name to check.</param>
587
        private bool InstanceNameIsValid(string name)
588
        {
589
            // Discard null, empty strings and white space only strings.
590
            // Look for the current name in the list of loaded instances.
591
            return !string.IsNullOrWhiteSpace(name) && !HasInstance(name);
2✔
592
        }
593

594
        /// <summary>
595
        /// Removes the instance from the config and saves.
596
        /// </summary>
597
        public void RemoveInstance(string name)
598
        {
599
            instances.Remove(name);
2✔
600
            Configuration.SetRegistryToInstances(instances);
2✔
601
        }
2✔
602

603
        /// <summary>
604
        /// Renames an instance in the config and saves.
605
        /// </summary>
606
        public void RenameInstance(string from, string to)
607
        {
608
            if (from != to)
2✔
609
            {
610
                if (instances.ContainsKey(to))
2✔
611
                {
612
                    throw new InstanceNameTakenKraken(to);
2✔
613
                }
614
                var inst = instances[from];
2✔
615
                instances.Remove(from);
2✔
616
                inst.Name = to;
2✔
617
                instances.Add(to, inst);
2✔
618
                Configuration.SetRegistryToInstances(instances);
2✔
619
            }
620
        }
2✔
621

622
        /// <summary>
623
        /// Sets the current instance.
624
        /// Throws an InvalidGameInstanceKraken if not found.
625
        /// </summary>
626
        public void SetCurrentInstance(string name)
627
        {
628
            if (!instances.TryGetValue(name, out GameInstance? inst))
2✔
629
            {
630
                throw new InvalidGameInstanceKraken(name);
2✔
631
            }
632
            else if (!inst.Valid)
2✔
633
            {
634
                throw new NotGameDirKraken(inst.GameDir);
×
635
            }
636
            else
637
            {
638
                SetCurrentInstance(inst);
2✔
639
            }
640
        }
2✔
641

642
        public void SetCurrentInstance(GameInstance? instance)
643
        {
644
            var prev = CurrentInstance;
2✔
645
            // Don't try to Dispose a null CurrentInstance.
646
            if (prev != null && !prev.Equals(instance))
2✔
647
            {
648
                // Dispose of the old registry manager to release the registry
649
                // (without accidentally locking/loading/etc it).
650
                RegistryManager.DisposeInstance(prev);
2✔
651
            }
652
            CurrentInstance = instance;
2✔
653
            InstanceChanged?.Invoke(prev, instance);
2✔
654
        }
×
655

656
        public void SetCurrentInstanceByPath(string path)
657
        {
658
            if (InstanceAt(path) is GameInstance inst)
2✔
659
            {
660
                SetCurrentInstance(inst);
2✔
661
            }
662
        }
2✔
663

664
        public GameInstance? InstanceAt(string path)
665
        {
666
            var di = new DirectoryInfo(path);
2✔
667
            if (DetermineGame(di, User) is IGame game)
2✔
668
            {
669
                var inst = new GameInstance(game, path,
2✔
670
                                            Properties.Resources.GameInstanceByPathName);
671
                if (inst.Valid)
2✔
672
                {
673
                    return inst;
2✔
674
                }
675
                else
676
                {
677
                    throw new NotGameDirKraken(inst.GameDir);
×
678
                }
679
            }
680
            return null;
×
681
        }
682

683
        /// <summary>
684
        /// Sets the autostart instance in the config and saves it.
685
        /// </summary>
686
        public void SetAutoStart(string name)
687
        {
688
            if (!HasInstance(name))
2✔
689
            {
690
                throw new InvalidGameInstanceKraken(name);
2✔
691
            }
692
            else if (!instances[name].Valid)
2✔
693
            {
694
                throw new NotGameDirKraken(instances[name].GameDir);
×
695
            }
696
            Configuration.AutoStartInstance = name;
2✔
697
        }
2✔
698

699
        public bool HasInstance(string name)
700
            => instances.ContainsKey(name);
2✔
701

702
        public void ClearAutoStart()
703
        {
704
            Configuration.AutoStartInstance = null;
2✔
705
        }
2✔
706

707
        private void LoadInstances()
708
        {
709
            log.Info("Loading game instances");
2✔
710

711
            instances.Clear();
2✔
712

713
            foreach (Tuple<string, string, string> instance in Configuration.GetInstances())
6✔
714
            {
715
                var name = instance.Item1;
2✔
716
                var path = instance.Item2;
2✔
717
                var gameName = instance.Item3;
2✔
718
                try
719
                {
720
                    var game = KnownGames.GameByShortName(gameName)
2✔
721
                               ?? KnownGames.knownGames.First();
722
                    log.DebugFormat("Loading {0} from {1}", name, Platform.FormatPath(path));
2✔
723
                    // Add unconditionally, sort out invalid instances downstream
724
                    instances.Add(name, new GameInstance(game, path, name));
2✔
725
                }
2✔
726
                catch (Exception exc)
×
727
                {
728
                    // Skip malformed instances (e.g. empty path)
729
                    log.Error($"Failed to load game instance with name=\"{name}\" path=\"{path}\" game=\"{gameName}\"",
×
730
                        exc);
731
                }
×
732
            }
733
        }
2✔
734

735
        private void LoadCacheSettings()
736
        {
737
            if (Configuration.DownloadCacheDir != null
2✔
738
                && !Directory.Exists(Configuration.DownloadCacheDir))
739
            {
740
                try
741
                {
742
                    Directory.CreateDirectory(Configuration.DownloadCacheDir);
2✔
743
                }
2✔
744
                catch
×
745
                {
746
                    // Can't create the configured directory, try reverting it to the default
747
                    Configuration.DownloadCacheDir = null;
×
748
                    Directory.CreateDirectory(DefaultDownloadCacheDir);
×
749
                }
×
750
            }
751

752
            var progress = new ProgressImmediate<int>(p => {});
×
753
            if (!TrySetupCache(Configuration.DownloadCacheDir,
2✔
754
                               progress,
755
                               out string? failReason)
756
                && Configuration.DownloadCacheDir is { Length: > 0 })
757
            {
758
                log.ErrorFormat("Cache not found at configured path {0}: {1}",
×
759
                                Configuration.DownloadCacheDir, failReason ?? "");
760
                // Fall back to default path to minimize chance of ending up in an invalid state at startup
761
                TrySetupCache(null, progress, out _);
×
762
            }
763
        }
2✔
764

765
        /// <summary>
766
        /// Switch to using a download cache in a new location
767
        /// </summary>
768
        /// <param name="path">Location of folder for new cache</param>
769
        /// <param name="progress">Progress object to report progress to</param>
770
        /// <param name="failureReason">Contains a human readable failure message if the setup failed</param>
771
        /// <returns>
772
        /// true if successful, false otherwise
773
        /// </returns>
774
        public bool TrySetupCache(string?        path,
775
                                  IProgress<int> progress,
776
                                  [NotNullWhen(false)]
777
                                  out string?    failureReason)
778
        {
779
            var origPath  = Configuration.DownloadCacheDir;
2✔
780
            var origCache = Cache;
2✔
781
            try
782
            {
783
                if (path is { Length: > 0 })
2✔
784
                {
785
                    Cache = new NetModuleCache(this, path);
2✔
786
                    if (path != origPath)
2✔
787
                    {
788
                        Configuration.DownloadCacheDir = path;
2✔
789
                    }
790
                }
791
                else
792
                {
793
                    Directory.CreateDirectory(DefaultDownloadCacheDir);
2✔
794
                    Cache = new NetModuleCache(this, DefaultDownloadCacheDir);
2✔
795
                    Configuration.DownloadCacheDir = null;
2✔
796
                }
797
                if (origPath != null && origCache != null && path != origPath)
2✔
798
                {
799
                    origCache.GetSizeInfo(out _, out long oldNumBytes, out _);
2✔
800
                    Cache.GetSizeInfo(out _, out _, out long? bytesFree);
2✔
801

802
                    if (oldNumBytes > 0)
2✔
803
                    {
804
                        switch (User.RaiseSelectionDialog(
×
805
                                    bytesFree.HasValue
806
                                        ? string.Format(Properties.Resources.GameInstanceManagerCacheMigrationPrompt,
807
                                                        CkanModule.FmtSize(oldNumBytes),
808
                                                        CkanModule.FmtSize(bytesFree.Value))
809
                                        : string.Format(Properties.Resources.GameInstanceManagerCacheMigrationPromptFreeSpaceUnknown,
810
                                                        CkanModule.FmtSize(oldNumBytes)),
811
                                    oldNumBytes < (bytesFree ?? 0) ? 0 : 2,
812
                                    Properties.Resources.GameInstanceManagerCacheMigrationMove,
813
                                    Properties.Resources.GameInstanceManagerCacheMigrationDelete,
814
                                    Properties.Resources.GameInstanceManagerCacheMigrationOpen,
815
                                    Properties.Resources.GameInstanceManagerCacheMigrationNothing,
816
                                    Properties.Resources.GameInstanceManagerCacheMigrationRevert))
817
                        {
818
                            case 0:
819
                                if (oldNumBytes < bytesFree)
×
820
                                {
821
                                    Cache.MoveFrom(new DirectoryInfo(origPath), progress);
×
822
                                    CacheChanged?.Invoke(origCache);
×
823
                                    origCache.Dispose();
×
824
                                }
825
                                else
826
                                {
827
                                    User.RaiseError(Properties.Resources.GameInstanceManagerCacheMigrationNotEnoughFreeSpace);
×
828
                                    // Abort since the user picked an option that doesn't work
829
                                    Cache = origCache;
×
830
                                    Configuration.DownloadCacheDir = origPath;
×
831
                                    failureReason = "";
×
832
                                }
833
                                break;
×
834

835
                            case 1:
836
                                origCache.RemoveAll();
×
837
                                CacheChanged?.Invoke(origCache);
×
838
                                origCache.Dispose();
×
839
                                break;
×
840

841
                            case 2:
842
                                Utilities.OpenFileBrowser(origPath);
×
843
                                Utilities.OpenFileBrowser(Configuration.DownloadCacheDir ?? DefaultDownloadCacheDir);
×
844
                                CacheChanged?.Invoke(origCache);
×
845
                                origCache.Dispose();
×
846
                                break;
×
847

848
                            case 3:
849
                                CacheChanged?.Invoke(origCache);
×
850
                                origCache.Dispose();
×
851
                                break;
×
852

853
                            case -1:
854
                            case 4:
855
                                Cache = origCache;
×
856
                                Configuration.DownloadCacheDir = origPath;
×
857
                                failureReason = "";
×
858
                                return false;
×
859
                        }
860
                    }
861
                    else
862
                    {
863
                        CacheChanged?.Invoke(origCache);
2✔
864
                        origCache.Dispose();
2✔
865
                    }
866
                }
867
                failureReason = null;
2✔
868
                return true;
2✔
869
            }
870
            catch (DirectoryNotFoundKraken)
×
871
            {
872
                Cache = origCache;
×
873
                Configuration.DownloadCacheDir = origPath;
×
874
                failureReason = string.Format(Properties.Resources.GameInstancePathNotFound, path);
×
875
                return false;
×
876
            }
877
            catch (Exception ex)
×
878
            {
879
                Cache = origCache;
×
880
                Configuration.DownloadCacheDir = origPath;
×
881
                failureReason = ex.Message;
×
882
                return false;
×
883
            }
884
        }
2✔
885

886
        /// <summary>
887
        /// Releases all resource used by the <see cref="GameInstance"/> object.
888
        /// </summary>
889
        /// <remarks>Call <see cref="Dispose"/> when you are finished using the <see cref="GameInstance"/>. The <see cref="Dispose"/>
890
        /// method leaves the <see cref="GameInstance"/> in an unusable state. After calling <see cref="Dispose"/>, you must
891
        /// release all references to the <see cref="GameInstance"/> so the garbage collector can reclaim the memory that
892
        /// the <see cref="GameInstance"/> was occupying.</remarks>
893
        public void Dispose()
894
        {
895
            Cache?.Dispose();
2✔
896
            Cache = null;
2✔
897

898
            // Attempting to dispose of the related RegistryManager object here is a bad idea, it cause loads of failures
899
            GC.SuppressFinalize(this);
2✔
900
        }
2✔
901

902
        public static bool IsGameInstanceDir(DirectoryInfo path)
903
            => KnownGames.knownGames.Any(g => g.GameInFolder(path));
2✔
904

905
        /// <summary>
906
        /// Tries to determine the game that is installed at the given path
907
        /// </summary>
908
        /// <param name="path">A DirectoryInfo of the path to check</param>
909
        /// <param name="user">IUser object for interaction</param>
910
        /// <returns>An instance of the matching game or null if the user cancelled</returns>
911
        /// <exception cref="NotGameDirKraken">Thrown when no games found</exception>
912
        public IGame? DetermineGame(DirectoryInfo path, IUser user)
913
            => 
914
                KnownGames.knownGames.Where(g => g.GameInFolder(path))
2✔
915
                                    .ToArray()
916
               switch
917
               {
918
                   { Length: 0 }       => throw new NotGameDirKraken(path.FullName),
2✔
919
                   { Length: 1 } games => games.Single(),
2✔
920
                   var games => user.RaiseSelectionDialog(
×
921
                                    string.Format(Properties.Resources.GameInstanceManagerSelectGamePrompt,
922
                                                  Platform.FormatPath(path.FullName)),
923
                                    games.Select(g => g.ShortName)
×
924
                                         .ToArray())
925
                                is int selection and >= 0
926
                                    ? games[selection]
927
                                    : null
928
               };
929

930
        public static readonly string DefaultDownloadCacheDir =
2✔
931
            Path.Combine(CKANPathUtils.AppDataPath, "downloads");
932
    }
933
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc