From 2d8e38d48a4889924c10540d834093b7e9a94ded Mon Sep 17 00:00:00 2001 From: Mathias Lui Date: Tue, 13 Jun 2023 21:32:05 +0200 Subject: [PATCH] Clean up SteamHelper and SteamGame * Also makes required adjustments in the DamageCalculator --- .../DamageCalculator/MainWindow.xaml.cs | 6 +- .../SteamShared/Models/MapPoint.cs | 27 +- .../SteamShared/Models/SteamGame.cs | 85 ++++++ .../SteamShared/SteamShared/SteamHelper.cs | 252 ++++++++++++------ 4 files changed, 280 insertions(+), 90 deletions(-) diff --git a/DamageCalculator/DamageCalculator/MainWindow.xaml.cs b/DamageCalculator/DamageCalculator/MainWindow.xaml.cs index c667310..be89750 100644 --- a/DamageCalculator/DamageCalculator/MainWindow.xaml.cs +++ b/DamageCalculator/DamageCalculator/MainWindow.xaml.cs @@ -127,7 +127,7 @@ namespace Damage_Calculator InitializeComponent(); Globals.LoadSettings(); - SteamShared.Globals.Settings.CsgoHelper.CsgoPath = SteamShared.Globals.Settings.SteamHelper.GetGamePathFromExactName("Counter-Strike: Global Offensive"); + SteamShared.Globals.Settings.CsgoHelper.CsgoPath = SteamShared.Globals.Settings.SteamHelper.GetGamePathFromExactName("Counter-Strike: Global Offensive", true); if (SteamShared.Globals.Settings.CsgoHelper.CsgoPath == null) { ShowMessage.Error("Make sure you have installed CS:GO and Steam correctly."); @@ -1843,7 +1843,7 @@ namespace Damage_Calculator // CS:GO is not opened, just ask if the user wants to start it if (MessageBox.Show("Do you want to start CS:GO now?", "Start CS:GO", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) { - SteamShared.Globals.Settings.SteamHelper.StartApp(SteamShared.CsgoHelper.GameID, additionalStartOptions); + SteamShared.Globals.Settings.SteamHelper.StartSteamApp(SteamShared.CsgoHelper.GameID, additionalStartOptions); ShowMessage.Info("CS:GO is currently attempting to start. Retry to connect when you're in-game.\n\nRetrying while on a map will load the corresponding map."); return; } @@ -1866,7 +1866,7 @@ namespace Damage_Calculator if (csgo.HasExited) { - SteamShared.Globals.Settings.SteamHelper.StartApp(SteamShared.CsgoHelper.GameID, additionalStartOptions); + SteamShared.Globals.Settings.SteamHelper.StartSteamApp(SteamShared.CsgoHelper.GameID, additionalStartOptions); ShowMessage.Info("CS:GO is currently attempting to restart. Retry to connect when you're in-game.\n\nRetrying while on a map will load the corresponding map."); return; diff --git a/SteamShared/SteamShared/SteamShared/Models/MapPoint.cs b/SteamShared/SteamShared/SteamShared/Models/MapPoint.cs index 9ad2e2a..6cc364c 100644 --- a/SteamShared/SteamShared/SteamShared/Models/MapPoint.cs +++ b/SteamShared/SteamShared/SteamShared/Models/MapPoint.cs @@ -8,29 +8,50 @@ namespace SteamShared.Models { public class MapPoint { + /// + /// The actual UI circle element of this point displayed on the map. + /// public System.Windows.Shapes.Ellipse? Circle { get; set; } + /// + /// The percentage that this point is at on the map's X axis (0% is left, 100% is right). + /// public double PercentageX { get; set; } + /// + /// The percentage that this point is at on the map's Y axis (0% is top, 100% is bottom). + /// public double PercentageY { get; set; } /// - /// The in-game X-coordinate. + /// The in-game X-coordinate. /// public double X { get; set; } /// - /// The in-game Y-coordinate. + /// The in-game Y-coordinate. /// public double Y { get; set; } /// - /// The in-game Z-coordinate, if any. + /// The in-game Z-coordinate, if any. /// public double? Z { get; set; } + /// + /// The ID of the area that this point was put on. + /// + /// + /// If there is no area associated, it will be negative. + /// public int AssociatedAreaID { get; set; } = -1; + /// + /// The percentage of how wide this circle is relative to the map's width or height. + /// + /// + /// Width or height doesn't matter as maps are square shaped. + /// public double PercentageScale { get; set; } } } diff --git a/SteamShared/SteamShared/SteamShared/Models/SteamGame.cs b/SteamShared/SteamShared/SteamShared/Models/SteamGame.cs index 771ace9..1c69b5d 100644 --- a/SteamShared/SteamShared/SteamShared/Models/SteamGame.cs +++ b/SteamShared/SteamShared/SteamShared/Models/SteamGame.cs @@ -1,38 +1,123 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; namespace SteamShared.Models { + /// + /// A game (or app) which contains mainly app manifest data. + /// public class SteamGame { + /// + /// The name of this game. + /// public string? Name { get; set; } + /// + /// The name of the folder this game's files are installed in, + /// e.g. for CS:GO it would be "Counter-Strike Global Offensive". + /// public string? InstallFolderName { get; set; } + /// + /// The absolute path of the steam library, which contains this game. + /// + public string? LinkedSteamLibraryPath { get; set; } + + /// + /// The absolute installation path of this game, including the , + /// or , if a puzzle piece of it is not specified. + /// + /// + /// The path might look like this on Windows:

+ /// + /// C:\My\Library\Path\steamapps\common\Super Cool Game
+ /// [ Library Path ] [ Xtra Folders ] [ Game Name ] + ///
+ public string? FullInstallPath + { + get + { + if (this.LinkedSteamLibraryPath is null || this.InstallFolderName is null) + return null; + + return System.IO.Path.Combine(LinkedSteamLibraryPath, "steamapps", "common", InstallFolderName); + } + } + + /// + /// Whether the game's directory () exists. + /// + /// + /// and need to be set. + /// + public bool GameFolderExists + { + get + { + return Directory.Exists(this.FullInstallPath); + } + } + + /// + /// The ID of this Steam App, e.g. for CS:GO this would be 730. + /// public int AppId { get; set; } + /// + /// The flags of this game, defined in . + /// Note, that these are *flags* and thus can have multiple values. + /// public int GameState { get; set; } + /// + /// The time this game was last updated at. + /// public DateTime LastUpdated { get; set; } + /// + /// The SteamID64 of the last owner user of this game. + /// public long LastOwnerSteam64Id { get; set; } + /// + /// The amount of bytes that are left to download. + /// public long BytesToDownload { get; set; } + /// + /// The amount of bytes that were already downloaded. + /// public long BytesDownloaded { get; set; } + /// + /// The amount of bytes that are left to be staged (Not 100% sure, what staging does). + /// public long BytesToStage { get; set; } + /// + /// The amount of bytes that were alread staged. + /// public long BytesStaged { get; set; } + /// + /// Whether Steam should keep this game updated automatically. + /// public bool KeepAutomaticallyUpdated { get; set; } + /// + /// Whether Steam is allowed to update other games and apps while this app is running. + /// public bool AllowOtherUpdatesWhileRunning { get; set; } } + /// + /// The GameState flags defined in the app manifests. + /// [Flags] enum GameState { diff --git a/SteamShared/SteamShared/SteamShared/SteamHelper.cs b/SteamShared/SteamShared/SteamShared/SteamHelper.cs index 5721846..04ca33d 100644 --- a/SteamShared/SteamShared/SteamShared/SteamHelper.cs +++ b/SteamShared/SteamShared/SteamShared/SteamHelper.cs @@ -18,8 +18,8 @@ namespace SteamShared public List? InstalledGames; /// - /// Gets the absolute path to the Steam install directory. - /// If it can't be fetched (i.e. Steam is not installed) null is returned. + /// The absolute path to the Steam install directory. + /// If it can't be fetched (i.e. Steam is not installed) null is returned. /// public string? SteamPath { @@ -34,8 +34,8 @@ namespace SteamShared } /// - /// Gets a list of all Steam libraries, and whether they're existent or not. - /// If it can't be fetched (i.e. Steam is not installed) null is returned. + /// Gets a list of all Steam libraries, and whether they're existent or not. + /// If it can't be fetched (i.e. Steam is not installed) null is returned. /// public List? SteamLibraries { @@ -50,7 +50,7 @@ namespace SteamShared } /// - /// Forcefully tries to update the property with the current Steam path, even if it should be already set. + /// Forcefully tries to update the property with the current Steam path, even if it should be already set. /// public void UpdateSteamPath() { @@ -58,9 +58,9 @@ namespace SteamShared } /// - /// Gets the path to the Steam install directory. (For external use is preferred.) + /// The path to the Steam install directory. (For external use is preferred.) /// - /// The absolute path to the Steam install directory, or null if it can't be fetched. + /// the absolute path to the Steam install directory, or null if it can't be fetched. public string? GetSteamPath() { var steamKey = Registry.CurrentUser.OpenSubKey("software\\valve\\steam"); @@ -98,7 +98,7 @@ namespace SteamShared // Usually the config.vdf had "BaseInstallFolder_" entries, // now it seems that these entries don't exist anymore with reinstalls, and maybe they're not up-to-date anyways? - // Now we try reading the "libraryfolders.vdf", which now also contains the default library location + // Now we try reading the "libraryfolders.vdf", which now also contains the default library location (In the Steam folder, by default under C:) #if NEWLIBRARYLOCATION string configFilePath = Path.Combine(this.steamPath, "config", "libraryfolders.vdf"); if (!File.Exists(configFilePath)) @@ -144,6 +144,10 @@ namespace SteamShared #endif } + /// + /// Updates the list of installed steam games. + /// + /// Whether to fetch them again, even if they were fetched before. public void UpdateInstalledGames(bool force = false) { if (!force && this.InstalledGames != null) @@ -152,10 +156,25 @@ namespace SteamShared this.InstalledGames = this.GetInstalledGames(); } + /// + /// Gets a list of fully installed Steam games, as seen by the manifest files. + /// + /// + /// Games are seen as fully installed, when their manifest file exists, + /// and the manifest states that the game is fully installed. + /// This means, that if the files are deleted manually, it might still be seen as installed, + /// because the manifest file might not change. + /// + /// + /// a list of installed Steam games, with some manifest data, + /// or , if no games could be fetched or found. + /// public List? GetInstalledGames() { + // Get all steam library paths var steamLibraries = this.GetSteamLibraries(); + // If the steam path couldn't be fetched or no libraries exist, we short-circuit if (steamLibraries == null) return null; @@ -163,10 +182,11 @@ namespace SteamShared foreach(var library in steamLibraries) { - if (!library.DoesExist) + if (!library.DoesExist || library.Path is null) continue; - List manifestFiles = Directory.GetFiles(Path.Combine(library.Path!, "steamapps")).ToList().Where(f => this.isAppManifestFile(f)).ToList(); + List manifestFiles = Directory.GetFiles(Path.Combine(library.Path, "steamapps")) + .Where(f => this.isAppManifestFile(f)).ToList(); foreach (string manifestFile in manifestFiles) { @@ -178,9 +198,13 @@ namespace SteamShared var root = manifestVDF["AppState"]; + if (root == null) + // Parse error of manifest, skip it + continue; + var currGame = new SteamGame(); - this.populateGameInfo(currGame, root!); + this.populateGameInfo(currGame, root, library.Path); if((currGame.GameState & (int)GameState.StateFullyInstalled) != 0) { @@ -193,69 +217,83 @@ namespace SteamShared return allGames; } - public string? GetGamePathFromExactName(string gameName) + /// + /// Gets the absolute path of the game name (not folder) provided. + /// + /// The name of the game. The case, as well as leading and trailing whitespaces don't matter. + /// Whether to only return it, if it's marked as fully installed. + /// + /// the absolute path of the game, or if not found, + /// the game's folder doesn't exist, or it wasn't marked as fully installed, when required to be. + /// + public string? GetGamePathFromExactName(string gameName, bool shouldBeFullyInstalled = false) { - var steamLibraries = this.GetSteamLibraries(); + // Will not update, if already updated once before + this.UpdateInstalledGames(); - if (steamLibraries == null) + if (this.InstalledGames is null) + // User is broke or something return null; - var allGames = new List(); + gameName = gameName.Trim(); - foreach (var library in steamLibraries) - { - if (!library.DoesExist) - continue; + var foundGame = this.InstalledGames.Where(game => game.Name is not null && game.Name.Trim().Equals(gameName, StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(); - List manifestFiles = Directory.GetFiles(Path.Combine(library.Path!, "steamapps")).ToList().Where(f => this.isAppManifestFile(f)).ToList(); + if (foundGame is null) + return null; - foreach (string manifestFile in manifestFiles) - { - var manifestVDF = new VDFFile(manifestFile); + if (!foundGame.GameFolderExists) + return null; - if (manifestVDF.RootElements.Count < 1) - // App manifest might be still existent but the game might not be installed (happened during testing) - continue; + if (shouldBeFullyInstalled + && (foundGame.GameState & (int)GameState.StateFullyInstalled) == 0) + return null; - var root = manifestVDF["AppState"]; - - if(root!["name"].Value!.Trim().ToLower() != gameName.Trim().ToLower()) - { - // Not our wanted game, skip - continue; - } - - var currGame = new SteamGame(); - - this.populateGameInfo(currGame, root); - - if ((currGame.GameState & (int)GameState.StateFullyInstalled) != 0) - { - // Game was fully installed according to steam - return Path.Combine(library.Path!, "steamapps", "common", currGame.InstallFolderName!); - } - } - } - - return null; + // Match the name while ignoring leading and trailing whitespaces, as well as upper/lower case. + return foundGame.FullInstallPath; } /// - /// Gets the most recently logged in Steam user, based on the "MostRecent" value. + /// Gets the most recently logged in Steam user, based on the "MostRecent" value. /// /// - /// The most recent logged in Steam user, or , if none has been found or an error has occurred. + /// the most recent logged in Steam user, or , if none has been found or an error has occurred. /// public SteamUser? GetMostRecentSteamUser() { - string steamPath = this.SteamPath; + List? steamUsers = this.GetSteamUsers(); - if (steamPath == null) + if (steamUsers == null) return null; + // Gets the user that has logged in most recently. + // We do this instead of checking, which user has the "MostRecent" VDF property set to 1 + SteamUser? mostRecentLoggedInSteamUser = steamUsers.OrderByDescending(user => user.LastLogin).FirstOrDefault(); + + return mostRecentLoggedInSteamUser; + } + + /// + /// Gets all Steam users from the loginusers.vdf file. + /// + /// + /// a list of users, or , if no users exist or there was an error. + /// + public List? GetSteamUsers() + { + string? steamPath = this.SteamPath; + + // SteamPath couldn't be fetched. + // This would break if Steam was installed to a different directory between fetching and using the steam path. + if (steamPath == null) + return null; + + // The path that probably contains all users that have logged in since Steam was installed string usersFilePath = Path.Combine(steamPath, "config", "loginusers.vdf"); if (!File.Exists(usersFilePath)) + // Where file? 🦧 return null; VDFFile vdf = new VDFFile(usersFilePath); @@ -265,44 +303,51 @@ namespace SteamShared if (users == null) return null; - SteamUser? mostRecentUser = null; + List? steamUsers = null; + // users may be empty here foreach (var user in users) { - if (int.TryParse(user["MostRecent"]?.Value, out int mostRecent) && Convert.ToBoolean(mostRecent)) + // Create the list if we have at least one *potential* user + if (steamUsers == null) + steamUsers = new List(); + + var steamUser = new SteamUser(); + + // This is not the user name, but the name of the VDF element, which in this case should be the steam ID 64 + if (ulong.TryParse(user.Name, out ulong steamID64)) { - // We found a "most recent" user - mostRecentUser = new SteamUser(); - - if(ulong.TryParse(user.Name, out ulong steamID64)) - { - mostRecentUser.SteamID64 = steamID64; - } - - mostRecentUser.AccountName = user["AccountName"].Value; - mostRecentUser.PersonaName = user["PersonaName"].Value; - - if (ulong.TryParse(user["Timestamp"].Value, out ulong lastLoginUnixTime)) - { - mostRecentUser.LastLogin = lastLoginUnixTime; - } - - mostRecentUser.AbsoluteUserdataFolderPath = Path.Combine(steamPath, "userdata", mostRecentUser.AccountID.ToString()); + steamUser.SteamID64 = steamID64; } + + steamUser.AccountName = user["AccountName"].Value; + steamUser.PersonaName = user["PersonaName"].Value; + + // "MostRecent" can later be found by getting the largest Timestamp + if (ulong.TryParse(user["Timestamp"].Value, out ulong lastLoginUnixTime)) + { + steamUser.LastLogin = lastLoginUnixTime; + } + + // The needed AccountID (Last part of the SteamId3) is calculated automatically from the SteamId64 + steamUser.AbsoluteUserdataFolderPath = Path.Combine(steamPath, "userdata", steamUser.AccountID.ToString()); + + steamUsers.Add(steamUser); } - return mostRecentUser; + return steamUsers; } /// - /// Starts the given steam game, with the given additional arguments, if possible. + /// Starts the given steam game, with the given additional arguments, if possible. /// - /// The ID of the game. - /// - /// The arguments passed to that game. - /// Note, that the default arguments set by the user in the UI are also passed to the app, these are just additional. + /// The ID of the game (e.g. 730 for CS:GO/CS2). + /// + /// The arguments passed to that game. + /// Note, that the default arguments set by the user in the UI are also passed to the app, + /// these are just additional to that. /// - public void StartApp(int gameID, string arguments) + public void StartSteamApp(int gameID, string additionalArgs) { string? steamPath = this.SteamPath; this.UpdateInstalledGames(); // Won't force update, if already set @@ -316,28 +361,66 @@ namespace SteamShared startInfo.UseShellExecute = false; // Make double sure startInfo.CreateNoWindow = false; startInfo.FileName = Path.Combine(steamPath, "steam.exe"); - startInfo.Arguments = $"-applaunch {gameID}" + (String.IsNullOrWhiteSpace(arguments) ? string.Empty : $" {arguments}"); + string extraArgs = String.IsNullOrWhiteSpace(additionalArgs) ? string.Empty : $" {additionalArgs}"; + + // The "-applaunch" argument will automatically add the args stored by the user. We add our own ones to that, if required. + startInfo.Arguments = $"-applaunch {gameID}" + extraArgs; + + // Fire and forget! Process.Start(startInfo); } #region Private Methods + /// + /// Checks, if the file at the given absolute path is considered an appmanifest, by the looks of it. + /// + /// + /// App manifest have the format "appmanifest_GAMEID.acf" + /// + /// The absolute path of the app manifest (acf) file. + /// + /// whether the file name matches the app manifest description. + /// private bool isAppManifestFile(string filePath) { - return System.Text.RegularExpressions.Regex.IsMatch(filePath.Split(new[] { '\\', '/' }).Last(), "appmanifest_\\d+.acf"); ; + string[] splitFilePath = filePath.Split(new[] { '\\', '/' }); + + if (splitFilePath.Length < 1) + // Doesn't seem to be a valid path + return false; + + return System.Text.RegularExpressions.Regex.IsMatch(splitFilePath.Last(), "appmanifest_\\d+.acf"); ; } - private DateTime fromUnixFormat(long unixFormat) + /// + /// Converts a unix time in seconds to a using the specified . + /// + /// The unix seconds. + /// The type of time zone, UTC by default. + /// + /// the that was created from the given seconds. + /// + private DateTime fromUnixFormat(long unixSeconds, DateTimeKind dateTimeKind = DateTimeKind.Utc) { - DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Local); - return dateTime.AddSeconds(unixFormat); + DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, dateTimeKind); + return dateTime.AddSeconds(unixSeconds); } - private void populateGameInfo(SteamGame game, Element appStateVdf) + /// + /// Takes a game object and populates it with the info from the app manifest file, + /// which is specified by the given VDF element. + /// + /// The game to populate with info. + /// The app state VDF element, which contains the required information. + /// The absolute path to the steam library containing this game. + private void populateGameInfo(SteamGame game, Element appStateVdf, string steamLibraryPath) { game.Name = appStateVdf["name"]?.Value; + // Setting these two properties enables the ability to fetch the FullInstallPath game.InstallFolderName = appStateVdf["installdir"]?.Value; + game.LinkedSteamLibraryPath = steamLibraryPath; if (int.TryParse(appStateVdf["appid"]?.Value, out int appId)) { @@ -351,7 +434,8 @@ namespace SteamShared if (long.TryParse(appStateVdf["LastUpdated"]?.Value, out long lastUpdated)) { - game.LastUpdated = fromUnixFormat(lastUpdated); + // It's unix time, but the time is in the local time zone, and not in UTC. + game.LastUpdated = fromUnixFormat(lastUpdated, DateTimeKind.Local); } if (long.TryParse(appStateVdf["LastOwner"]?.Value, out long lastOwner))