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

KSP-CKAN / CKAN / 15655026327

14 Jun 2025 06:40PM UTC coverage: 27.152% (-3.2%) from 30.329%
15655026327

Pull #4392

github

web-flow
Merge bb1dadfef into 1b4a54286
Pull Request #4392: Writethrough when saving files, add Netkan tests

3703 of 12085 branches covered (30.64%)

Branch coverage included in aggregate %.

19 of 32 new or added lines in 18 files covered. (59.38%)

9 existing lines in 7 files now uncovered.

8041 of 31168 relevant lines covered (25.8%)

0.53 hits per line

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

53.21
/Core/GameInstance.cs
1
using System;
2
using System.Collections.Generic;
3
using System.IO;
4
using System.Linq;
5
using System.Reflection;
6
using System.Text.RegularExpressions;
7
using System.Diagnostics;
8
using System.Diagnostics.CodeAnalysis;
9

10
using ChinhDo.Transactions.FileManager;
11
using log4net;
12
using Newtonsoft.Json;
13

14
using CKAN.IO;
15
using CKAN.Configuration;
16
using CKAN.Games;
17
using CKAN.Versioning;
18
using CKAN.Extensions;
19

20
namespace CKAN
21
{
22

23
    /// <summary>
24
    /// Everything for dealing with a game folder.
25
    /// </summary>
26
    public class GameInstance : IEquatable<GameInstance>
27
    {
28
        #region Fields and Properties
29

30
        public IUser User { get; private set; }
31

32
        private readonly string gameDir;
33
        public readonly IGame game;
34
        private GameVersion? version;
35
        private List<GameVersion> _compatibleVersions = new List<GameVersion>();
2✔
36

37
        public TimeLog? playTime;
38

39
        public string Name { get; set; }
40

41
        /// <summary>
42
        /// Returns a file system safe version of the instance name that can be used within file names.
43
        /// </summary>
44
        public string SanitizedName => string.Join("", Name.Split(Path.GetInvalidFileNameChars()));
2✔
45
        public GameVersion? GameVersionWhenCompatibleVersionsWereStored { get; private set; }
46
        public bool CompatibleVersionsAreFromDifferentGameVersion
47
            => _compatibleVersions.Count > 0
×
48
               && GameVersionWhenCompatibleVersionsWereStored != Version();
49

50
        private static readonly ILog log = LogManager.GetLogger(typeof(GameInstance));
2✔
51

52
        #endregion
53

54
        #region Construction and Initialisation
55

56
        /// <summary>
57
        /// Returns a KSP object.
58
        /// Will initialise a CKAN instance in the KSP dir if it does not already exist,
59
        /// if the directory contains a valid KSP install.
60
        /// </summary>
61
        public GameInstance(IGame game, string gameDir, string name, IUser? user)
2✔
62
        {
2✔
63
            this.game = game;
2✔
64
            Name = name;
2✔
65
            User = user ?? new NullUser();
2✔
66
            // Make sure our path is absolute and has normalised slashes.
67
            this.gameDir = CKANPathUtils.NormalizePath(Path.GetFullPath(gameDir));
2✔
68
            if (Platform.IsWindows)
2!
69
            {
×
70
                // Normalized slashes are bad for pure drive letters,
71
                // Path.Combine turns them into drive-relative paths like
72
                // K:GameData/whatever
73
                if (Regex.IsMatch(this.gameDir, @"^[a-zA-Z]:$"))
×
74
                {
×
75
                    this.gameDir = $"{this.gameDir}/";
×
76
                }
×
77
            }
×
78
            if (Valid)
2✔
79
            {
2✔
80
                SetupCkanDirectories();
2✔
81
                LoadCompatibleVersions();
2✔
82
            }
2✔
83
        }
2✔
84

85
        /// <returns>
86
        /// true if the game seems to be here and a version is found,
87
        /// false otherwise
88
        /// </returns>
89
        public bool Valid => game.GameInFolder(new DirectoryInfo(gameDir)) && Version() != null;
2✔
90

91
        /// <returns>
92
        /// true if the instance may be locked, false otherwise.
93
        /// Note that this is a tentative value; if it's true,
94
        /// we still need to try to acquire the lock to confirm it isn't stale.
95
        /// NOTE: Will throw NotKSPDirKraken if the instance isn't valid!
96
        ///       Either be prepared to catch that exception, or check Valid first to avoid it.
97
        /// </returns>
98
        public bool IsMaybeLocked => RegistryManager.IsInstanceMaybeLocked(CkanDir());
×
99

100
        /// <summary>
101
        /// Create the CKAN directory and any supporting files.
102
        /// </summary>
103
        [MemberNotNull(nameof(playTime))]
104
        private void SetupCkanDirectories()
105
        {
2✔
106
            log.InfoFormat("Initialising {0}", CkanDir());
2✔
107

108
            // TxFileManager knows if we are in a transaction
109
            TxFileManager txFileMgr = new TxFileManager();
2✔
110

111
            if (!Directory.Exists(CkanDir()))
2✔
112
            {
2✔
113
                User.RaiseMessage(Properties.Resources.GameInstanceSettingUp);
2✔
114
                User.RaiseMessage(Properties.Resources.GameInstanceCreatingDir, CkanDir());
2✔
115
                txFileMgr.CreateDirectory(CkanDir());
2✔
116
            }
2✔
117

118
            playTime = TimeLog.Load(TimeLog.GetPath(CkanDir())) ?? new TimeLog();
2✔
119

120
            if (!Directory.Exists(InstallHistoryDir()))
2✔
121
            {
2✔
122
                User.RaiseMessage(Properties.Resources.GameInstanceCreatingDir, InstallHistoryDir());
2✔
123
                txFileMgr.CreateDirectory(InstallHistoryDir());
2✔
124
            }
2✔
125
            log.InfoFormat("Initialised {0}", CkanDir());
2✔
126
        }
2✔
127

128
        #endregion
129

130
        #region Settings
131

132
        public void SetCompatibleVersions(List<GameVersion> compatibleVersions)
133
        {
2✔
134
            _compatibleVersions = compatibleVersions.Distinct()
2✔
135
                                                    .OrderByDescending(v => v)
2✔
136
                                                    .ToList();
137
            SaveCompatibleVersions();
2✔
138
        }
2✔
139

140
        private void SaveCompatibleVersions()
141
        {
2✔
142
            JsonConvert.SerializeObject(new CompatibleGameVersions()
2✔
143
                {
144
                    GameVersionWhenWritten = Version()?.ToString(),
145
                    Versions               = _compatibleVersions.Select(v => v.ToString())
2✔
146
                                                                .OfType<string>()
147
                                                                .ToList()
148
                })
149
                .WriteThroughTo(CompatibleGameVersionsFile());
150
            GameVersionWhenCompatibleVersionsWereStored = Version();
2✔
151
        }
2✔
152

153
        private void LoadCompatibleVersions()
154
        {
2✔
155
            string path = CompatibleGameVersionsFile();
2✔
156
            if (File.Exists(path)
2!
157
                && JsonConvert.DeserializeObject<CompatibleGameVersions>(File.ReadAllText(path))
158
                   is CompatibleGameVersions compatibleGameVersions)
159
            {
×
160
                _compatibleVersions = compatibleGameVersions.Versions
×
161
                                                            .Select(GameVersion.Parse)
162
                                                            .ToList();
163

164
                // Get version without throwing exceptions for null
165
                GameVersion.TryParse(compatibleGameVersions.GameVersionWhenWritten, out GameVersion? mainVer);
×
166
                GameVersionWhenCompatibleVersionsWereStored = mainVer;
×
167
            }
×
168
            else if (Version() is GameVersion gv)
2!
169
            {
2✔
170
                _compatibleVersions = game.DefaultCompatibleVersions(gv)
2✔
171
                                          .ToList();
172
            }
2✔
173
        }
2✔
174

175
        private string CompatibleGameVersionsFile()
176
            => Path.Combine(CkanDir(), game.CompatibleVersionsFile);
2✔
177

178
        public List<GameVersion> GetCompatibleVersions()
179
            => new List<GameVersion>(_compatibleVersions);
×
180

181
        public HashSet<string> GetSuppressedCompatWarningIdentifiers
182
            => SuppressedCompatWarningIdentifiers.LoadFrom(Version(), SuppressedCompatWarningIdentifiersFile)
×
183
                                                 .Identifiers;
184

185
        public void AddSuppressedCompatWarningIdentifiers(HashSet<string> idents)
186
        {
×
187
            var scwi = SuppressedCompatWarningIdentifiers.LoadFrom(Version(), SuppressedCompatWarningIdentifiersFile);
×
188
            scwi.Identifiers.UnionWith(idents);
×
189
            scwi.SaveTo(SuppressedCompatWarningIdentifiersFile);
×
190
        }
×
191

192
        private string SuppressedCompatWarningIdentifiersFile
193
            => Path.Combine(CkanDir(), "suppressed_compat_warning_identifiers.json");
×
194

195
        public string[] InstallFilters
196
        {
197
            get => (File.Exists(InstallFiltersFile)
2!
198
                        ? JsonConvert.DeserializeObject<string[]>(File.ReadAllText(InstallFiltersFile))
199
                        : null)
200
                   ?? Array.Empty<string>();
201

202
            #pragma warning disable IDE0027
203
            set
204
            {
×
NEW
205
                JsonConvert.SerializeObject(value)
×
206
                           .WriteThroughTo(InstallFiltersFile);
UNCOV
207
            }
×
208
            #pragma warning restore IDE0027
209
        }
210

211
        private string InstallFiltersFile => Path.Combine(CkanDir(), "install_filters.json");
2✔
212

213
        public StabilityToleranceConfig StabilityToleranceConfig
214
            => stabilityToleranceConfig ??= new StabilityToleranceConfig(StabilityToleranceFile);
2✔
215

216
        private StabilityToleranceConfig? stabilityToleranceConfig;
217

218
        private string StabilityToleranceFile
219
            => Path.Combine(CkanDir(), "stability_tolerance.json");
2✔
220

221
        #endregion
222

223
        #region KSP Directory Detection and Versioning
224

225
        /// <summary>
226
        /// Returns the path to our portable version of KSP if ckan.exe is in the same
227
        /// directory as the game, or if the game is in the current directory.
228
        /// Otherwise, returns null.
229
        /// </summary>
230
        public static string? PortableDir(IGame game)
231
        {
2✔
232
            string curDir = Directory.GetCurrentDirectory();
2✔
233

234
            log.DebugFormat("Checking if {0} is in my current dir: {1}",
2✔
235
                game.ShortName, curDir);
236

237
            if (game.GameInFolder(new DirectoryInfo(curDir)))
2!
238
            {
×
239
                log.InfoFormat("{0} found at {1}", game.ShortName, curDir);
×
240
                return curDir;
×
241
            }
242

243
            // Find the directory our executable is stored in.
244
            var exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
2✔
245
            if (string.IsNullOrEmpty(exeDir)
2!
246
                && Process.GetCurrentProcess()?.MainModule?.FileName is string s)
247
            {
×
248
                exeDir = Path.GetDirectoryName(s);
×
249
                if (string.IsNullOrEmpty(exeDir))
×
250
                {
×
251
                    log.InfoFormat("Executing assembly path and main module path not found");
×
252
                    return null;
×
253
                }
254
                log.InfoFormat("Executing assembly path not found, main module path is {0}", exeDir);
×
255
            }
×
256

257
            log.DebugFormat("Checking if {0} is in my exe dir: {1}",
2✔
258
                game.ShortName, exeDir);
259

260
            if (curDir != exeDir && exeDir != null
2!
261
                && game.GameInFolder(new DirectoryInfo(exeDir)))
262
            {
×
263
                log.InfoFormat("{0} found at {1}", game.ShortName, exeDir);
×
264
                return exeDir;
×
265
            }
266

267
            return null;
2✔
268
        }
2✔
269

270
        /// <summary>
271
        /// Detects the version of a game in a given directory.
272
        /// </summary>
273
        private GameVersion? DetectVersion(string directory)
274
        {
2✔
275
            var version = game.DetectVersion(new DirectoryInfo(directory));
2✔
276
            if (version != null)
2✔
277
            {
2✔
278
                log.DebugFormat("Found version {0}", version);
2✔
279
            }
2✔
280
            return version;
2✔
281
        }
2✔
282

283
        #endregion
284

285
        #region Things which would be better as Properties
286

287
        public string GameDir()
288
            => gameDir;
2✔
289

290
        public string CkanDir()
291
        {
2✔
292
            if (!Valid)
2!
293
            {
×
294
                log.Error("Could not find game version");
×
295
                throw new NotKSPDirKraken(gameDir, Properties.Resources.GameInstanceVersionNotFound);
×
296
            }
297
            return CKANPathUtils.NormalizePath(
2✔
298
                Path.Combine(GameDir(), "CKAN"));
299
        }
2✔
300

301
        public string DownloadCacheDir()
302
            => CKANPathUtils.NormalizePath(Path.Combine(CkanDir(), "downloads"));
2✔
303

304
        public string InstallHistoryDir()
305
            => CKANPathUtils.NormalizePath(Path.Combine(CkanDir(), "history"));
2✔
306

307
        public IOrderedEnumerable<FileInfo> InstallHistoryFiles()
308
            => Directory.EnumerateFiles(InstallHistoryDir(), "*.ckan")
×
309
                        .Select(f => new FileInfo(f))
×
310
                        .OrderByDescending(fi => fi.CreationTime);
×
311

312
        public GameVersion? Version()
313
        {
2✔
314
            version ??= DetectVersion(GameDir());
2✔
315
            return version;
2✔
316
        }
2✔
317

318
        public GameVersionCriteria VersionCriteria()
319
            => new GameVersionCriteria(Version(), _compatibleVersions);
2✔
320

321
        #endregion
322

323
        /// <summary>
324
        /// Returns path relative to this KSP's GameDir.
325
        /// </summary>
326
        public string ToRelativeGameDir(string path)
327
            => CKANPathUtils.ToRelative(path, GameDir());
2✔
328

329
        /// <summary>
330
        /// Given a path relative to this KSP's GameDir, returns the
331
        /// absolute path on the system.
332
        /// </summary>
333
        public string ToAbsoluteGameDir(string path)
334
            => CKANPathUtils.ToAbsolute(path, GameDir());
2✔
335

336
        /// <summary>
337
        /// https://xkcd.com/208/
338
        /// This regex matches things like GameData/Foo/Foo.1.2.dll
339
        /// </summary>
340
        private static readonly Regex dllPattern = new Regex(
2✔
341
            @"
342
                ^(?:.*/)?             # Directories (ending with /)
343
                (?<identifier>[^.]+)  # Our DLL name, up until the first dot.
344
                .*\.dll$              # Everything else, ending in dll
345
            ",
346
            RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled
347
        );
348

349
        /// <summary>
350
        /// Find the identifier associated with a manually installed DLL
351
        /// </summary>
352
        /// <param name="relative_path">Path of the DLL relative to game root</param>
353
        /// <returns>
354
        /// Identifier if found otherwise null
355
        /// </returns>
356
        public string? DllPathToIdentifier(string relative_path)
357
        {
2✔
358
            var paths = Enumerable.Repeat(game.PrimaryModDirectoryRelative, 1)
2✔
359
                                  .Concat(game.AlternateModDirectoriesRelative);
360
            if (!paths.Any(p => relative_path.StartsWith($"{p}/", Platform.PathComparison)))
2!
361
            {
×
362
                // DLLs only live in the primary or alternate mod directories
363
                return null;
×
364
            }
365
            Match match = dllPattern.Match(relative_path);
2✔
366
            return match.Success ? Identifier.Sanitize(match.Groups["identifier"].Value)
2!
367
                                 : null;
368
        }
2✔
369

370
        /// <summary>
371
        /// Generate a sequence of files in the game folder that weren't installed by CKAN
372
        /// </summary>
373
        /// <param name="registry">A Registry object that knows which files CKAN installed in this folder</param>
374
        /// <returns>Relative file paths as strings</returns>
375
        public IEnumerable<string> UnmanagedFiles(Registry registry)
376
            => Directory.EnumerateFiles(gameDir, "*", SearchOption.AllDirectories)
×
377
                        .Select(CKANPathUtils.NormalizePath)
378
                        .Where(absPath => !absPath.StartsWith(CkanDir()))
×
379
                        .Select(ToRelativeGameDir)
380
                        .Where(relPath =>
381
                            !game.StockFolders.Any(f => relPath.StartsWith($"{f}/"))
×
382
                            && registry.FileOwner(relPath) == null);
383

384
        /// <summary>
385
        /// Check whether a given path contains any files or folders installed by CKAN
386
        /// </summary>
387
        /// <param name="registry">A Registry object that knows which files CKAN installed in this folder</param>
388
        /// <param name="absPath">Absolute path to a folder to check</param>
389
        /// <returns>true if any descendants of given path were installed by CKAN, false otherwise</returns>
390
        public bool HasManagedFiles(Registry registry, string absPath)
391
            => registry.FileOwner(ToRelativeGameDir(absPath)) != null
×
392
               || (Directory.Exists(absPath)
393
                   && Directory.EnumerateFileSystemEntries(absPath, "*", SearchOption.AllDirectories)
394
                               .Any(f => registry.FileOwner(ToRelativeGameDir(f)) != null));
×
395

396
        public void PlayGame(string command, Action? onExit = null)
397
        {
×
398
            if (game.AdjustCommandLine(command.Split(' '), Version())
×
399
                //is [string binary, ..] and string[] split
400
                is string[] split
401
                && split.Length > 0
402
                && split[0] is string binary)
403
            {
×
404
                try
405
                {
×
406
                    Directory.SetCurrentDirectory(GameDir());
×
407
                    Process p = new Process()
×
408
                    {
409
                        StartInfo = new ProcessStartInfo()
410
                        {
411
                            FileName  = binary,
412
                            Arguments = string.Join(" ", split.Skip(1))
413
                        },
414
                        EnableRaisingEvents = true
415
                    };
416

417
                    var isSteam = SteamLibrary.IsSteamCmdLine(command);
×
418
                    p.Exited += (sender, e) =>
×
419
                    {
×
420
                        if (!isSteam)
×
421
                        {
×
422
                            playTime?.Stop(CkanDir());
×
423
                        }
×
424
                        onExit?.Invoke();
×
425
                    };
×
426

427
                    p.Start();
×
428
                    if (!isSteam)
×
429
                    {
×
430
                        playTime?.Start();
×
431
                    }
×
432
                }
×
433
                catch (Exception exception)
×
434
                {
×
435
                    User.RaiseError(Properties.Resources.GameInstancePlayGameFailed, exception.Message);
×
436
                }
×
437
            }
×
438
        }
×
439

440
        public override string ToString()
441
            => string.Format(Properties.Resources.GameInstanceToString, game.ShortName, gameDir);
×
442

443
        public bool Equals(GameInstance? other)
444
            => other != null && gameDir.Equals(other.GameDir());
2✔
445

446
        public override bool Equals(object? obj)
447
            => Equals(obj as GameInstance);
×
448

449
        public override int GetHashCode()
450
            => gameDir.GetHashCode();
×
451
    }
452

453
}
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