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

KSP-CKAN / CKAN / 25686862322

11 May 2026 05:41PM UTC coverage: 87.571% (+0.08%) from 87.496%
25686862322

Pull #4617

github

web-flow
Merge 10e11e29b into a52681fb3
Pull Request #4617: Scale tooltips on Mono

1989 of 2117 branches covered (93.95%)

Branch coverage included in aggregate %.

8516 of 9879 relevant lines covered (86.2%)

2.69 hits per line

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

89.9
/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;
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
    /// <summary>
23
    /// Everything for dealing with a game folder.
24
    /// </summary>
25
    public class GameInstance : IEquatable<GameInstance>
26
    {
27
        #region Construction and Initialisation
28

29
        /// <summary>
30
        /// Returns a game instance object.
31
        /// Will initialise a CKAN instance in the game dir if it does not already exist.
32
        /// </summary>
33
        public GameInstance(IGame game, string gameDir, string name, IUser? user)
3✔
34
        {
35
            Game = game;
3✔
36
            Name = name;
3✔
37
            User = user ?? new NullUser();
3✔
38
            // Make sure our path is absolute and has normalised slashes.
39
            GameDir = CKANPathUtils.NormalizePath(Path.GetFullPath(gameDir));
3✔
40
            if (Platform.IsWindows)
3✔
41
            {
42
                // Normalized slashes are bad for pure drive letters,
43
                // Path.Combine turns them into drive-relative paths like
44
                // K:GameData/whatever
45
                if (Regex.IsMatch(GameDir, @"^[a-zA-Z]:$"))
1✔
46
                {
47
                    GameDir = $"{GameDir}/";
×
48
                }
49
            }
50
            SetupCkanDirectories();
3✔
51
            LoadCompatibleVersions();
3✔
52
        }
3✔
53

54
        /// <returns>
55
        /// true if the game seems to be here and a version is found,
56
        /// false otherwise
57
        /// </returns>
58
        public bool Valid => Game.GameInFolder(new DirectoryInfo(GameDir)) && Version() != null;
3✔
59

60
        /// <returns>
61
        /// true if the instance may be locked, false otherwise.
62
        /// Note that this is a tentative value; if it's true,
63
        /// we still need to try to acquire the lock to confirm it isn't stale.
64
        /// </returns>
65
        public bool IsMaybeLocked => RegistryManager.IsInstanceMaybeLocked(CkanDir);
×
66

67
        /// <summary>
68
        /// Create the CKAN directory and any supporting files.
69
        /// </summary>
70
        [MemberNotNull(nameof(playTime))]
71
        private void SetupCkanDirectories()
72
        {
73
            log.InfoFormat("Initialising {0}", CkanDir);
3✔
74

75
            // TxFileManager knows if we are in a transaction
76
            var txFileMgr = new TxFileManager(CkanDir);
3✔
77

78
            if (!Directory.Exists(CkanDir))
3✔
79
            {
80
                User.RaiseMessage(Properties.Resources.GameInstanceSettingUp);
×
81
                User.RaiseMessage(Properties.Resources.GameInstanceCreatingDir, CkanDir);
×
82
                txFileMgr.CreateDirectory(CkanDir);
×
83
            }
84

85
            playTime = TimeLog.Load(TimeLog.GetPath(CkanDir)) ?? new TimeLog();
3✔
86

87
            if (!Directory.Exists(InstallHistoryDir))
3✔
88
            {
89
                User.RaiseMessage(Properties.Resources.GameInstanceCreatingDir, InstallHistoryDir);
3✔
90
                txFileMgr.CreateDirectory(InstallHistoryDir);
3✔
91
            }
92
            log.InfoFormat("Initialised {0}", CkanDir);
3✔
93
        }
3✔
94

95
        #endregion
96

97
        #region Fields and Properties
98

99
        public IUser User { get; private set; }
100

101
        public string Name { get; set; }
102

103
        /// <summary>
104
        /// Returns a file system safe version of the instance name that can be used within file names.
105
        /// </summary>
106
        public string SanitizedName => string.Join("", Name.Split(Path.GetInvalidFileNameChars()));
3✔
107

108
        public readonly string GameDir;
109
        public readonly IGame Game;
110
        private GameVersion? version;
111

112
        public TimeLog? playTime;
113

114
        public GameVersion? GameVersionWhenCompatibleVersionsWereStored
115
            => _compatibleVersions.GameVersionWhenWritten;
×
116

117
        public bool CompatibleVersionsAreFromDifferentGameVersion
118
            => GameVersionWhenCompatibleVersionsWereStored != Version();
×
119

120
        private CompatibleGameVersions _compatibleVersions;
121

122
        private static readonly ILog log = LogManager.GetLogger(typeof(GameInstance));
3✔
123

124
        public string CkanDir
125
            => ckanDir ??= CKANPathUtils.NormalizePath(Path.Combine(GameDir, "CKAN"));
3✔
126

127
        public string DownloadCacheDir
128
            => downloadDir ??= CKANPathUtils.NormalizePath(Path.Combine(CkanDir, "downloads"));
3✔
129

130
        public string InstallHistoryDir
131
            => historyDir ??= CKANPathUtils.NormalizePath(Path.Combine(CkanDir, "history"));
3✔
132

133
        private string? ckanDir;
134
        private string? downloadDir;
135
        private string? historyDir;
136

137
        public IOrderedEnumerable<FileInfo> InstallHistoryFiles()
138
            => Directory.EnumerateFiles(InstallHistoryDir, "*.ckan")
3✔
139
                        .Select(f => new FileInfo(f))
3✔
140
                        .OrderByDescending(fi => fi.CreationTime);
3✔
141

142
        public GameVersion? Version()
143
            => version ??= DetectVersion(GameDir);
3✔
144

145
        public GameVersionCriteria VersionCriteria()
146
            => new GameVersionCriteria(Version(), _compatibleVersions.Versions);
3✔
147

148
        #endregion
149

150
        #region Settings
151

152
        [MemberNotNull(nameof(_compatibleVersions))]
153
        private void LoadCompatibleVersions()
154
        {
155
            string path = CompatibleGameVersionsFile;
3✔
156
            if (File.Exists(path)
3✔
157
                && JsonConvert.DeserializeObject<CompatibleGameVersions>(File.ReadAllText(path))
158
                   is CompatibleGameVersions compatibleGameVersions)
159
            {
160
                _compatibleVersions = compatibleGameVersions;
3✔
161
            }
162
            else
163
            {
164
                _compatibleVersions = new CompatibleGameVersions()
3✔
165
                {
166
                    GameVersionWhenWritten = null,
167
                    Versions               = Version() is GameVersion gv
168
                                                 ? Game.DefaultCompatibleVersions(gv).ToList()
169
                                                 : new List<GameVersion>(),
170
                };
171
            }
172
        }
3✔
173

174
        [MemberNotNull(nameof(_compatibleVersions))]
175
        public void SetCompatibleVersions(IReadOnlyCollection<GameVersion> compatibleVersions)
176
        {
177
            _compatibleVersions = new CompatibleGameVersions()
3✔
178
            {
179
                GameVersionWhenWritten = Version(),
180
                Versions               = compatibleVersions.Distinct()
181
                                                           .OrderDescending()
182
                                                           .ToList(),
183
            };
184
            JsonConvert.SerializeObject(_compatibleVersions)
3✔
185
                       .WriteThroughTo(CompatibleGameVersionsFile);
186
        }
3✔
187

188
        private string CompatibleGameVersionsFile
189
            => Path.Combine(CkanDir, Game.CompatibleVersionsFile);
3✔
190

191
        public IReadOnlyCollection<GameVersion> CompatibleVersions => _compatibleVersions.Versions;
3✔
192

193
        public HashSet<string> GetSuppressedCompatWarningIdentifiers
194
            => SuppressedCompatWarningIdentifiers.LoadFrom(Version(), SuppressedCompatWarningIdentifiersFile)
3✔
195
                                                 .Identifiers;
196

197
        public void AddSuppressedCompatWarningIdentifiers(HashSet<string> idents)
198
        {
199
            var scwi = SuppressedCompatWarningIdentifiers.LoadFrom(Version(), SuppressedCompatWarningIdentifiersFile);
3✔
200
            scwi.Identifiers.UnionWith(idents);
3✔
201
            scwi.SaveTo(SuppressedCompatWarningIdentifiersFile);
3✔
202
        }
3✔
203

204
        private string SuppressedCompatWarningIdentifiersFile
205
            => Path.Combine(CkanDir, "suppressed_compat_warning_identifiers.json");
3✔
206

207
        public string[] InstallFilters
208
        {
209
            get => (File.Exists(InstallFiltersFile)
3✔
210
                        ? JsonConvert.DeserializeObject<string[]>(File.ReadAllText(InstallFiltersFile))
211
                        : null)
212
                   ?? Array.Empty<string>();
213

214
            #pragma warning disable IDE0027
215
            set
216
            {
217
                JsonConvert.SerializeObject(value)
3✔
218
                           .WriteThroughTo(InstallFiltersFile);
219
            }
3✔
220
            #pragma warning restore IDE0027
221
        }
222

223
        private string InstallFiltersFile => Path.Combine(CkanDir, "install_filters.json");
3✔
224

225
        public StabilityToleranceConfig StabilityToleranceConfig
226
            => stabilityToleranceConfig ??= new StabilityToleranceConfig(StabilityToleranceFile);
3✔
227

228
        private StabilityToleranceConfig? stabilityToleranceConfig;
229

230
        private string StabilityToleranceFile
231
            => Path.Combine(CkanDir, "stability_tolerance.json");
3✔
232

233
        #endregion
234

235
        #region Game Directory Detection and Versioning
236

237
        /// <summary>
238
        /// Returns the path to our portable version of game if ckan.exe is in the same
239
        /// directory as the game, or if the game is in the current directory.
240
        /// Otherwise, returns null.
241
        /// </summary>
242
        public static string? PortableDir(IGame game)
243
            => new string?[]
3✔
244
               {
245
                   PathToRunningExe(),
246
                   Process.GetCurrentProcess()?.MainModule?.FileName,
247
               }
248
                   .OfType<string>()
249
                   .Select(Path.GetDirectoryName)
250
                   .OfType<string>()
251
                   .Prepend(Directory.GetCurrentDirectory())
252
                   .Select(path => new DirectoryInfo(path))
3✔
253
                   .FirstOrDefault(game.GameInFolder)
254
                   ?.FullName;
255

256
        private static string? PathToRunningExe()
257
            #if NET5_0_OR_GREATER
258
            => Environment.ProcessPath;
259
            #else
260
            => Assembly.GetEntryAssembly()?.Location;
3✔
261
            #endif
262

263
        /// <summary>
264
        /// Detects the version of a game in a given directory.
265
        /// </summary>
266
        private GameVersion? DetectVersion(string directory)
267
            => Game.DetectVersion(new DirectoryInfo(directory));
3✔
268

269
        #endregion
270

271
        /// <summary>
272
        /// Returns path relative to this instance's GameDir.
273
        /// </summary>
274
        public string ToRelativeGameDir(string path)
275
            => CKANPathUtils.ToRelative(path, GameDir);
3✔
276

277
        /// <summary>
278
        /// Given a path relative to this instance's GameDir, returns the
279
        /// absolute path on the system.
280
        /// </summary>
281
        public string ToAbsoluteGameDir(string path)
282
            => CKANPathUtils.ToAbsolute(path, GameDir);
3✔
283

284
        /// <summary>
285
        /// https://xkcd.com/208/
286
        /// This regex matches things like GameData/Foo/Foo.1.2.dll
287
        /// </summary>
288
        private static readonly Regex dllPattern = new Regex(
3✔
289
            @"
290
                ^(?:.*/)?             # Directories (ending with /)
291
                (?<identifier>[^.]+)  # Our DLL name, up until the first dot.
292
                .*\.dll$              # Everything else, ending in dll
293
            ",
294
            RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled
295
        );
296

297
        /// <summary>
298
        /// Find the identifier associated with a manually installed DLL
299
        /// </summary>
300
        /// <param name="relPath">Path of the DLL relative to game root</param>
301
        /// <returns>
302
        /// Identifier if found otherwise null
303
        /// </returns>
304
        public string? DllPathToIdentifier(string relPath)
305
            => DllPathToIdentifier(Game, relPath);
3✔
306

307
        public static string? DllPathToIdentifier(IGame game, string relPath)
308
            // DLLs only live in the primary or alternate mod directories
309
            => game.AlternateModDirectoriesRelative
3✔
310
                   .Prepend(game.PrimaryModDirectoryRelative)
311
                   .Any(p => relPath.StartsWith($"{p}/", Platform.PathComparison))
3✔
312
               && dllPattern.Match(relPath) is { Success: true } match
313
                   ? Identifier.Sanitize(match.Groups["identifier"].Value)
314
                   : null;
315

316
        /// <summary>
317
        /// Generate a sequence of files in the game folder that weren't installed by CKAN
318
        /// </summary>
319
        /// <param name="registry">A Registry object that knows which files CKAN installed in this folder</param>
320
        /// <returns>Relative file paths as strings</returns>
321
        public IEnumerable<string> UnmanagedFiles(Registry registry)
322
            => Directory.EnumerateFiles(GameDir, "*", SearchOption.AllDirectories)
3✔
323
                        .Select(CKANPathUtils.NormalizePath)
324
                        .Where(absPath => !absPath.StartsWith(CkanDir))
3✔
325
                        .Select(ToRelativeGameDir)
326
                        .Where(relPath =>
327
                            !Game.StockFolders.Any(f => relPath.StartsWith($"{f}/"))
3✔
328
                            && registry.FileOwner(relPath) == null);
329

330
        /// <summary>
331
        /// Check whether a given path contains any files or folders installed by CKAN
332
        /// </summary>
333
        /// <param name="registry">A Registry object that knows which files CKAN installed in this folder</param>
334
        /// <param name="absPath">Absolute path to a folder to check</param>
335
        /// <returns>true if any descendants of given path were installed by CKAN, false otherwise</returns>
336
        public bool HasManagedFiles(Registry registry, string absPath)
337
            => registry.FileOwner(ToRelativeGameDir(absPath)) != null
3✔
338
               || (Directory.Exists(absPath)
339
                   && Directory.EnumerateFileSystemEntries(absPath, "*", SearchOption.AllDirectories)
340
                               .Any(f => registry.FileOwner(ToRelativeGameDir(f)) != null));
3✔
341

342
        /// <summary>
343
        /// Returns the number of bytes on disk consumed by this game instance
344
        /// </summary>
345
        public long TotalSize => Directory.EnumerateFiles(GameDir, "*", SearchOption.AllDirectories)
1✔
346
                                          .Sum(path => new FileInfo(path).Length);
1✔
347

348
        /// <summary>
349
        /// Returns the number of bytes needed to clone this game instance if hard links are allowed
350
        /// </summary>
351
        public long NonHardLinkableSize(string[] leaveEmpty)
352
            => Utilities.DirectoryNonHardLinkableSize(
3✔
353
                   new DirectoryInfo(GameDir),
354
                   new string[] { "CKAN/registry.locked", "CKAN/playtime.json" },
355
                   Platform.IsWindows ? Game.StockFolders
356
                                      : Array.Empty<string>(),
357
                   leaveEmpty,
358
                   new string[] { "CKAN" });
359

360
        [ExcludeFromCodeCoverage]
361
        public void PlayGame(string command, Action? onExit = null)
362
        {
363
            if (Game.AdjustCommandLine(command.Split(' '), Version())
364
                //is [string binary, ..] and string[] split
365
                is string[] split
366
                && split.Length > 0
367
                && split[0] is string binary)
368
            {
369
                try
370
                {
371
                    Directory.SetCurrentDirectory(GameDir);
372
                    Process p = new Process()
373
                    {
374
                        StartInfo = new ProcessStartInfo()
375
                        {
376
                            FileName  = binary,
377
                            Arguments = string.Join(" ", split.Skip(1))
378
                        },
379
                        EnableRaisingEvents = true
380
                    };
381

382
                    var isSteam = SteamLibrary.IsSteamCmdLine(command);
383
                    p.Exited += (sender, e) =>
384
                    {
385
                        if (!isSteam)
386
                        {
387
                            playTime?.Stop(CkanDir);
388
                        }
389
                        onExit?.Invoke();
390
                    };
391

392
                    p.Start();
393
                    if (!isSteam)
394
                    {
395
                        playTime?.Start();
396
                    }
397
                }
398
                catch (Exception exception)
399
                {
400
                    User.RaiseError(Properties.Resources.GameInstancePlayGameFailed, exception.Message);
401
                }
402
            }
403
        }
404

405
        public override string ToString()
406
            => string.Format(Properties.Resources.GameInstanceToString, Game.ShortName, GameDir);
×
407

408
        public bool Equals(GameInstance? other)
409
            => other != null && GameDir.Equals(other.GameDir);
3✔
410

411
        public override bool Equals(object? obj)
412
            => Equals(obj as GameInstance);
×
413

414
        public override int GetHashCode()
415
            => GameDir.GetHashCode();
×
416
    }
417
}
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