diff --git a/.gitignore b/.gitignore index 955d082..af9d72e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ # Visual Studio stuff .vs/ SrcMod/.vs/ -*.sln # Compiled Files SrcMod/Compiled SrcMod/Shell/obj/ +SrcMod/Valve.NET/obj + # Personal Stuff +SrcMod/Shell/Modules/TestingModule.cs TODO.md diff --git a/SrcMod/Shell/Game.cs b/SrcMod/Shell/Game.cs index 1a21734..2e0e23c 100644 --- a/SrcMod/Shell/Game.cs +++ b/SrcMod/Shell/Game.cs @@ -1,6 +1,6 @@ namespace SrcMod.Shell; -public class Game +public class Game : IEquatable { public static readonly Game Portal2 = new() { @@ -8,12 +8,48 @@ public class Game NameId = "portal2", SteamId = 620 }; + public static readonly Game Unknown = new() + { + Name = "Unknown Game", + NameId = "unknown", + SteamId = -1, + IsUnknown = true + }; - public required string Name { get; init; } - public required string NameId { get; init; } - public required int SteamId { get; init; } + public string Name { get; private set; } + public string NameId { get; private set; } + public int SteamId { get; private set; } - private Game() { } + public bool IsUnknown { get; private set; } + + private Game() + { + IsUnknown = false; + Name = string.Empty; + NameId = string.Empty; + } + + public static Game FromSteamId(int id) + { + if (id == Portal2.SteamId) return Portal2; + else + { + Game game = (Game)Unknown.MemberwiseClone(); + game.SteamId = id; + return game; + } + } + + public override bool Equals(object? obj) + { + if (obj is Game game) return Equals(game); + return false; + } + public bool Equals(Game? other) => other is not null && SteamId == other.SteamId; + public override int GetHashCode() => base.GetHashCode(); public override string ToString() => Name; + + public static bool operator ==(Game a, Game b) => a.Equals(b); + public static bool operator !=(Game a, Game b) => !a.Equals(b); } diff --git a/SrcMod/Shell/GlobalUsings.cs b/SrcMod/Shell/Miscellaneous/GlobalUsings.cs similarity index 77% rename from SrcMod/Shell/GlobalUsings.cs rename to SrcMod/Shell/Miscellaneous/GlobalUsings.cs index 19bbcbb..b5c13b0 100644 --- a/SrcMod/Shell/GlobalUsings.cs +++ b/SrcMod/Shell/Miscellaneous/GlobalUsings.cs @@ -1,12 +1,16 @@ -global using Nerd_STF.Mathematics; +global using Microsoft.Win32; +global using Nerd_STF.Mathematics; global using Newtonsoft.Json; global using SharpCompress.Archives.Rar; global using SharpCompress.Archives.SevenZip; global using SharpCompress.Readers; global using SrcMod.Shell.Extensions; global using SrcMod.Shell.Interop; +global using SrcMod.Shell.Modules; global using SrcMod.Shell.Modules.ObjectModels; global using SrcMod.Shell.ObjectModels; +global using SrcMod.Shell.ObjectModels.Source; +global using SrcMod.Shell.ObjectModels.Steam; global using System; global using System.Collections; global using System.Collections.Generic; @@ -20,4 +24,5 @@ global using System.Reflection; global using System.Runtime.InteropServices; global using System.Text; global using System.Threading; +global using Valve.Vkv; global using static SrcMod.Shell.Tools; diff --git a/SrcMod/Shell/Mod.cs b/SrcMod/Shell/Mod.cs index b4472d5..fef57fb 100644 --- a/SrcMod/Shell/Mod.cs +++ b/SrcMod/Shell/Mod.cs @@ -2,24 +2,183 @@ public class Mod { + public Game BaseGame { get; set; } + + public string? Developer { get; set; } + public string? DeveloperUrl { get; set; } + public Dictionary SearchPaths { get; set; } + public string? ManualUrl { get; set; } + + public string? FgdDataPath { get; set; } + public string? IconPath { get; set; } + public string? InstancePath { get; set; } + + public PlayerType PlayerMode { get; set; } + + public CrosshairFlags CrosshairMenuFlags { get; set; } + public bool ShowDifficultyMenu { get; set; } + public bool ShowModelMenu { get; set; } + public bool ShowPortalMenu { get; set; } + public SupportFlags SupportingFlags { get; set; } + + public bool HiResModels { get; set; } + + public string[] HiddenMaps { get; set; } + public string Name { get; set; } + public string? Motto { get; set; } + public TitleDisplay TitleDisplayMode { get; set; } + + public bool BuildMapNodegraphs { get; set; } + + public Dictionary? MapbaseLaunchOptions { get; set; } + public string RootDirectory { get; set; } private Mod() { + BaseGame = Game.Unknown; + SearchPaths = new(); + HiddenMaps = Array.Empty(); Name = string.Empty; + RootDirectory = string.Empty; + } + + public static Mod FromInfo(string root, GameInfo info) + { + Mod curMod = new() + { + BaseGame = Game.FromSteamId(info.FileSystem.SteamAppID), + BuildMapNodegraphs = info.Nodegraph is not null && info.Nodegraph.Value, + CrosshairMenuFlags = CrosshairFlags.None, + Developer = info.Developer, + DeveloperUrl = info.Developer_URL, + FgdDataPath = info.GameData, + HiddenMaps = info.Hidden_Maps is null ? Array.Empty() : info.Hidden_Maps.Keys.ToArray(), + HiResModels = info.NoHIModel is null || !info.NoHIModel.Value, + IconPath = info.Icon is null ? null : info.Icon.Trim().Replace('/', '\\') + ".tga", + InstancePath = info.InstancePath, + MapbaseLaunchOptions = info.CommandLine, + ManualUrl = info.Manual, + Motto = info.Title2, + Name = string.IsNullOrEmpty(info.Title) ? "Default Mod" : info.Title, + PlayerMode = info.Type is null ? PlayerType.Both : info.Type.Trim().ToLower() switch + { + "singleplayer_only" => PlayerType.Singleplayer, + "multiplayer_only" => PlayerType.Multiplayer, + _ => throw new ArgumentException($"Unknown type \"{info.Type}\"") + }, + RootDirectory = root, + SearchPaths = new(), + ShowDifficultyMenu = info.NoDifficulty is null || !info.NoDifficulty.Value, + ShowModelMenu = info.NoModels is null || !info.NoModels.Value, + ShowPortalMenu = info.HasPortals is not null && info.HasPortals.Value, + SupportingFlags = SupportFlags.None, + TitleDisplayMode = info.GameLogo is null ? TitleDisplay.Title : + (info.GameLogo.Value ? TitleDisplay.Logo : TitleDisplay.Title) + }; + if (curMod.PlayerMode == PlayerType.Multiplayer && info.NoDifficulty is null) + curMod.ShowDifficultyMenu = false; + + if (info.NoCrosshair is null || !info.NoCrosshair.Value) + curMod.CrosshairMenuFlags |= CrosshairFlags.ShowMultiplayer; + if (info.AdvCrosshair is not null && info.AdvCrosshair.Value) + curMod.CrosshairMenuFlags |= CrosshairFlags.AdvancedMenu; + + if (info.SupportsDX8 is not null && info.SupportsDX8.Value) + curMod.SupportingFlags |= SupportFlags.DirectX8; + if (info.SupportsVR is not null && info.SupportsVR.Value) + curMod.SupportingFlags |= SupportFlags.VirtualReality; + if (info.SupportsXBox360 is not null && info.SupportsXBox360.Value) + curMod.SupportingFlags |= SupportFlags.XBox360; + + foreach (KeyValuePair pair in info.FileSystem.SearchPaths) + { + SearchPathType type = SearchPathType.Unknown; + string[] parts = pair.Key.Trim().ToLower().Split('+'); + foreach (string part in parts) type |= part switch + { + "game" => SearchPathType.Game, + "game_write" => SearchPathType.GameWrite, + "gamebin" => SearchPathType.GameBinaries, + "platform" => SearchPathType.Platform, + "mod" => SearchPathType.Mod, + "mod_write" => SearchPathType.ModWrite, + "default_write_path" => SearchPathType.DefaultWritePath, + "vpk" => SearchPathType.Vpk, + _ => SearchPathType.Unknown + }; + } + + return curMod; } public static Mod? ReadDirectory(string dir) { - if (!File.Exists(dir + "\\GameInfo.txt")) return null; + dir = dir.Trim().Replace('/', '\\'); + string check = dir; - Mod mod = new() + while (!string.IsNullOrEmpty(check)) { - Name = dir.Split("\\").Last() - }; + string gameInfoPath = Path.Combine(check, "GameInfo.txt"); + if (File.Exists(gameInfoPath)) + { + // Root mod directory found, go from here. - return mod; + FileStream fs = new(gameInfoPath, FileMode.Open); + GameInfo? modInfo = SerializeVkv.Deserialize(fs); + if (modInfo is null) continue; + + return FromInfo(check, modInfo); + } + + check = Path.GetDirectoryName(check) ?? string.Empty; // Go to parent folder. + } + + return null; } public override string ToString() => Name; + + [Flags] + public enum CrosshairFlags + { + None = 0, + ShowMultiplayer = 1, + AdvancedMenu = 2 + } + + [Flags] + public enum SupportFlags + { + None, + DirectX8 = 1, + VirtualReality = 2, + XBox360 = 4 + } + + [Flags] + public enum SearchPathType + { + Unknown = 0, + Game = 1, + GameWrite = 2, + GameBinaries = 4, + Platform = 8, + Mod = 16, + ModWrite = 32, + DefaultWritePath = 64, + Vpk = 128 + } + + public enum PlayerType + { + Singleplayer = 1, + Multiplayer = 2, + Both = Singleplayer | Multiplayer + } + public enum TitleDisplay + { + Title, + Logo + } } diff --git a/SrcMod/Shell/Modules/ObjectModels/TypeParsers.cs b/SrcMod/Shell/Modules/ObjectModels/TypeParsers.cs index 954f9be..01d71ff 100644 --- a/SrcMod/Shell/Modules/ObjectModels/TypeParsers.cs +++ b/SrcMod/Shell/Modules/ObjectModels/TypeParsers.cs @@ -2,35 +2,73 @@ public static class TypeParsers { + public static bool CanParse(object? obj) => obj is not null && obj is bool or sbyte or byte or short or ushort + or int or uint or long or ulong or Int128 or UInt128 or nint or nuint or Half or float or double or decimal + or char or DateOnly or DateTime or DateTimeOffset or Guid or TimeOnly or TimeSpan; public static object ParseAll(string msg) { - if (TryParse(msg, out sbyte int8)) return int8; - if (TryParse(msg, out byte uInt8)) return uInt8; - if (TryParse(msg, out short int16)) return int16; - if (TryParse(msg, out ushort uInt16)) return uInt16; - if (TryParse(msg, out int int32)) return int32; - if (TryParse(msg, out uint uInt32)) return uInt32; - if (TryParse(msg, out long int64)) return int64; - if (TryParse(msg, out ulong uInt64)) return uInt64; - if (TryParse(msg, out Int128 int128)) return int128; - if (TryParse(msg, out UInt128 uInt128)) return uInt128; - if (TryParse(msg, out nint intPtr)) return intPtr; - if (TryParse(msg, out nuint uIntPtr)) return uIntPtr; - if (TryParse(msg, out Half float16)) return float16; - if (TryParse(msg, out float float32)) return float32; - if (TryParse(msg, out double float64)) return float64; - if (TryParse(msg, out decimal float128)) return float128; - if (TryParse(msg, out char resChar)) return resChar; - if (TryParse(msg, out DateOnly dateOnly)) return dateOnly; - if (TryParse(msg, out DateTime dateTime)) return dateTime; - if (TryParse(msg, out DateTimeOffset dateTimeOffset)) return dateTimeOffset; - if (TryParse(msg, out Guid guid)) return guid; - if (TryParse(msg, out TimeOnly timeOnly)) return timeOnly; - if (TryParse(msg, out TimeSpan timeSpan)) return timeSpan; - - return msg; + if (TryParseBool(msg, out bool resBool)) return resBool; + else if (TryParse(msg, out sbyte int8)) return int8; + else if (TryParse(msg, out byte uInt8)) return uInt8; + else if (TryParse(msg, out short int16)) return int16; + else if (TryParse(msg, out ushort uInt16)) return uInt16; + else if (TryParse(msg, out int int32)) return int32; + else if (TryParse(msg, out uint uInt32)) return uInt32; + else if (TryParse(msg, out long int64)) return int64; + else if (TryParse(msg, out ulong uInt64)) return uInt64; + else if (TryParse(msg, out Int128 int128)) return int128; + else if (TryParse(msg, out UInt128 uInt128)) return uInt128; + else if (TryParse(msg, out nint intPtr)) return intPtr; + else if (TryParse(msg, out nuint uIntPtr)) return uIntPtr; + else if (TryParse(msg, out Half float16)) return float16; + else if (TryParse(msg, out float float32)) return float32; + else if (TryParse(msg, out double float64)) return float64; + else if (TryParse(msg, out decimal float128)) return float128; + else if (TryParse(msg, out char resChar)) return resChar; + else if (TryParse(msg, out DateOnly dateOnly)) return dateOnly; + else if (TryParse(msg, out DateTime dateTime)) return dateTime; + else if (TryParse(msg, out DateTimeOffset dateTimeOffset)) return dateTimeOffset; + else if (TryParse(msg, out Guid guid)) return guid; + else if (TryParse(msg, out TimeOnly timeOnly)) return timeOnly; + else if (TryParse(msg, out TimeSpan timeSpan)) return timeSpan; + else return msg; } + public static bool TryParseBool(string msg, out bool result) + { + string trimmed = msg.Trim().ToLower(); + + string[] trues = new string[] + { + "t", + "true", + "1", + "y", + "yes" + }, + falses = new string[] + { + "f", + "false", + "0", + "n", + "no" + }; + + foreach (string t in trues) if (trimmed == t) + { + result = true; + return true; + } + foreach (string f in falses) if (trimmed == f) + { + result = false; + return true; + } + + result = false; + return false; + } public static bool TryParse(string msg, out T? result) where T : IParsable => T.TryParse(msg, null, out result); } diff --git a/SrcMod/Shell/ObjectModels/Config.cs b/SrcMod/Shell/ObjectModels/Config.cs index 197b7e6..f7121e2 100644 --- a/SrcMod/Shell/ObjectModels/Config.cs +++ b/SrcMod/Shell/ObjectModels/Config.cs @@ -4,6 +4,9 @@ public class Config { public const string FilePath = "config.json"; + public static bool HasDisplayableError => false; + public static bool HasDisplayableWarning => p_printedLastSteamWarning; + public static Config Defaults => new(); private static readonly FieldInfo[] p_configSharedFields; @@ -22,8 +25,14 @@ public class Config private static Config p_applied; private static Changes? p_changes; + private static bool p_printedLastSteamWarning; + + // These variables should only exist in the Config class so they aren't marked as shared. + private readonly string p_steamLocation; + static Config() { + // Generate shared fields between the config class and its changes equivalent. p_applied = Defaults; FieldInfo[] configFields = (from field in typeof(Config).GetFields() @@ -62,11 +71,67 @@ public class Config public string[] GameDirectories; public AskMode RunUnsafeCommands; + public bool UseLocalModDirectories; internal Config() { - GameDirectories = Array.Empty(); + // Locate some steam stuff. + const string steamLocationKey = @"Software\Valve\Steam"; + RegistryKey? key = Registry.CurrentUser.OpenSubKey(steamLocationKey); + if (key is null) + { + Write("[FATAL] Cannot locate Steam installation. Do you have Steam installed?", + ConsoleColor.DarkRed); + Thread.Sleep(1000); + BaseModule.QuitShell(-1); + + // This should never run, and is just here to supress + // a couple compiler warnings. + p_steamLocation = string.Empty; + GameDirectories = Array.Empty(); + RunUnsafeCommands = AskMode.Ask; + return; + } + p_steamLocation = (string)key.GetValue("SteamPath")!; + + // Assign config variables. + + string gameDirDataPath = Path.Combine(p_steamLocation, @"steamapps\libraryfolders.vdf"); + + FileStream gameDirData = new(gameDirDataPath, FileMode.Open); + try + { + LibraryFolder[]? folders = SerializeVkv.Deserialize(gameDirData); + if (folders is null) + { + if (!p_printedLastSteamWarning) + Write("[WARNING] Error parsing Steam game directories.", ConsoleColor.DarkYellow); + GameDirectories = Array.Empty(); + p_printedLastSteamWarning = true; + } + else + { + GameDirectories = new string[folders.Length]; + for (int i = 0; i < folders.Length; i++) GameDirectories[i] = folders[i].path; + p_printedLastSteamWarning = false; + } + } + catch (Exception ex) + { + if (!p_printedLastSteamWarning) + { +#if RELEASE + Write("[WARNING] Error parsing Steam game directories.", ConsoleColor.DarkYellow); +#else + Write(ex, ConsoleColor.DarkYellow); +#endif + } + GameDirectories = Array.Empty(); + p_printedLastSteamWarning = true; + } + RunUnsafeCommands = AskMode.Ask; + UseLocalModDirectories = true; } public Config ApplyChanges(Changes changes) @@ -132,7 +197,7 @@ public class Config } StreamReader reader = new(fullPath); JsonTextReader jsonReader = new(reader); - p_changes = Serializer.Deserialize(jsonReader); + p_changes = Tools.SerializerJson.Deserialize(jsonReader); jsonReader.Close(); reader.Close(); @@ -153,7 +218,7 @@ public class Config { Indentation = 4 }; - Serializer.Serialize(jsonWriter, p_changes); + Tools.SerializerJson.Serialize(jsonWriter, p_changes); jsonWriter.Close(); writer.Close(); } @@ -167,6 +232,7 @@ public class Config { public string[]? GameDirectories; public AskMode? RunUnsafeCommands; + public bool? UseLocalModDirectories; public bool Any() => typeof(Changes).GetFields().Any(x => x.GetValue(this) is not null); } diff --git a/SrcMod/Shell/ObjectModels/Source/GameInfo.cs b/SrcMod/Shell/ObjectModels/Source/GameInfo.cs new file mode 100644 index 0000000..c65396e --- /dev/null +++ b/SrcMod/Shell/ObjectModels/Source/GameInfo.cs @@ -0,0 +1,59 @@ +namespace SrcMod.Shell.ObjectModels.Source; + +// Referencing https://developer.valvesoftware.com/wiki/Gameinfo.txt. +public class GameInfo +{ + // Name + public string Game; + public string Title; + public string? Title2; + public bool? GameLogo; + + // Options + public string? Type; + public bool? NoDifficulty; + public bool? HasPortals; + public bool? NoCrosshair; + public bool? AdvCrosshair; + public bool? NoModels; + public bool? NoHIModel; + + public Dictionary? Hidden_Maps; + public Dictionary? CommandLine; + + // Steam games list + public string? Developer; + public string? Developer_URL; + public string? Manual; + public string? Icon; + + // Engine and tools + public bool? Nodegraph; + public string? GameData; + public string? InstancePath; + public bool? SupportsDX8; + public bool? SupportsVR; + public bool? SupportsXBox360; + public FileSystemData FileSystem; + + public GameInfo() + { + Game = string.Empty; + Title = string.Empty; + FileSystem = new(); + } + + public class FileSystemData + { + public int SteamAppID; + public int? AdditionalContentId; + public int? ToolsAppId; + + public Dictionary SearchPaths; + + public FileSystemData() + { + SearchPaths = new(); + } + } +} diff --git a/SrcMod/Shell/ObjectModels/Steam/LibraryFolder.cs b/SrcMod/Shell/ObjectModels/Steam/LibraryFolder.cs new file mode 100644 index 0000000..9ebe8e9 --- /dev/null +++ b/SrcMod/Shell/ObjectModels/Steam/LibraryFolder.cs @@ -0,0 +1,13 @@ +namespace SrcMod.Shell.ObjectModels.Steam; + +public class LibraryFolder +{ + public string path; + public Dictionary apps; + + public LibraryFolder() + { + path = string.Empty; + apps = new(); + } +} diff --git a/SrcMod/Shell/Shell.cs b/SrcMod/Shell/Shell.cs index 7fe1d15..766e479 100644 --- a/SrcMod/Shell/Shell.cs +++ b/SrcMod/Shell/Shell.cs @@ -4,7 +4,13 @@ public class Shell { public const string Author = "That_One_Nerd"; public const string Name = "SrcMod"; - public const string Version = "Alpha 0.4.0"; + public const string Version = "Beta 0.5.0"; + + public bool HasAnyDisplayableError => HasDisplayableError || Config.HasDisplayableError; + public bool HasAnyDisplayableWarning => HasDisplayableWarning || Config.HasDisplayableWarning; + + public bool HasDisplayableError => p_printedLastReloadError; + public bool HasDisplayableWarning => false; public readonly string? ShellDirectory; @@ -16,10 +22,12 @@ public class Shell public List History; public string WorkingDirectory; - private bool lastCancel; - private bool printedCancel; + private bool p_lastCancel; + private bool p_printedCancel; - private BackgroundWorker? activeCommand; + private bool p_printedLastReloadError; + + private BackgroundWorker? p_activeCommand; public Shell() { @@ -88,8 +96,8 @@ public class Shell Write(" by ", ConsoleColor.White, false); Write($"{Author}", ConsoleColor.DarkYellow); - lastCancel = false; - activeCommand = null; + p_lastCancel = false; + p_activeCommand = null; Console.CancelKeyPress += HandleCancel; ActiveGame = null; @@ -139,9 +147,21 @@ public class Shell public string ReadLine() { - Write($"\n{WorkingDirectory}", ConsoleColor.DarkGreen, false); - if (ActiveGame is not null) Write($" {ActiveGame}", ConsoleColor.DarkYellow, false); - if (ActiveMod is not null) Write($" {ActiveMod}", ConsoleColor.Magenta, false); + Write("\n", newLine: false); + if (HasAnyDisplayableError) Write($"(Error) ", ConsoleColor.DarkRed, false); + else if (HasAnyDisplayableWarning) Write($"(Warning) ", ConsoleColor.DarkYellow, false); + + if (ActiveMod is not null) Write($"{ActiveMod} ", ConsoleColor.Magenta, false); + + if (ActiveMod is not null && Config.LoadedConfig.UseLocalModDirectories) + { + string directory = Path.GetRelativePath(ActiveMod.RootDirectory, WorkingDirectory); + if (directory == ".") directory = string.Empty; + Write($"~\\{directory}", ConsoleColor.DarkGreen, false); + } + else Write($"{WorkingDirectory}", ConsoleColor.DarkGreen, false); + + if (ActiveGame is not null) Write($" ({ActiveGame})", ConsoleColor.Blue, false); Write(null); Write($" {Name}", ConsoleColor.DarkCyan, false); @@ -149,7 +169,7 @@ public class Shell bool printed = false; - if (lastCancel && !printedCancel) + if (p_lastCancel && !p_printedCancel) { // Print the warning. A little bit of mess because execution must // continue without funny printing errors but it's alright I guess. @@ -160,7 +180,7 @@ public class Shell Write("Press ^C again to exit the shell.", ConsoleColor.Red); PlayWarningSound(); - printedCancel = true; + p_printedCancel = true; Console.CursorTop += 2; Console.CursorLeft = originalLeft; @@ -175,8 +195,8 @@ public class Shell if (!printed) { - lastCancel = false; - printedCancel = false; + p_lastCancel = false; + p_printedCancel = false; } return message; @@ -262,18 +282,18 @@ public class Shell } } - activeCommand = new(); - activeCommand.DoWork += runCommand; - activeCommand.RunWorkerAsync(); + p_activeCommand = new(); + p_activeCommand.DoWork += runCommand; + p_activeCommand.RunWorkerAsync(); - activeCommand.WorkerSupportsCancellation = command.CanBeCancelled; + p_activeCommand.WorkerSupportsCancellation = command.CanBeCancelled; - while (activeCommand is not null && activeCommand.IsBusy) Thread.Yield(); + while (p_activeCommand is not null && p_activeCommand.IsBusy) Thread.Yield(); - if (activeCommand is not null) + if (p_activeCommand is not null) { - activeCommand.Dispose(); - activeCommand = null; + p_activeCommand.Dispose(); + p_activeCommand = null; } if (ShellDirectory is null) Write("[WARNING] Could not save config to shell location. Any changes will be ignored."); @@ -300,24 +320,45 @@ public class Shell public void ReloadDirectoryInfo() { - ActiveMod = Mod.ReadDirectory(WorkingDirectory); + try + { + ActiveMod = Mod.ReadDirectory(WorkingDirectory); + ActiveGame = ActiveMod?.BaseGame; - // Update title. - string title = "SrcMod"; - if (ActiveMod is not null) title += $" - {ActiveMod.Name}"; - Console.Title = title; + // Update title. + string title = "SrcMod"; + if (ActiveMod is not null) title += $" - {ActiveMod.Name}"; + Console.Title = title; + + p_printedLastReloadError = false; + } + catch (Exception ex) + { + if (!p_printedLastReloadError) + { +#if RELEASE + Write("[ERROR] Error reloading directory information. Some data may not update.", + ConsoleColor.Red); +#else + Write(ex, ConsoleColor.Red); +#endif + } + + p_printedLastReloadError = true; + Console.Title = "SrcMod (Error)"; + } } private void HandleCancel(object? sender, ConsoleCancelEventArgs args) { - if (activeCommand is not null && activeCommand.IsBusy) + if (p_activeCommand is not null && p_activeCommand.IsBusy) { - if (activeCommand.WorkerSupportsCancellation) + if (p_activeCommand.WorkerSupportsCancellation) { // Kill the active command. - activeCommand.CancelAsync(); - activeCommand.Dispose(); - activeCommand = null; + p_activeCommand.CancelAsync(); + p_activeCommand.Dispose(); + p_activeCommand = null; } else { @@ -326,18 +367,18 @@ public class Shell PlayErrorSound(); } - lastCancel = false; - printedCancel = false; + p_lastCancel = false; + p_printedCancel = false; args.Cancel = true; return; } // Due to some funny multithreading issues, we want to make the warning label // single-threaded on the shell. - if (!lastCancel) + if (!p_lastCancel) { // Enable the warning. The "ReadLine" method will do the rest. - lastCancel = true; + p_lastCancel = true; args.Cancel = true; // "Cancel" referring to the cancellation of the cancel operation. return; } diff --git a/SrcMod/Shell/Shell.csproj b/SrcMod/Shell/Shell.csproj index 253835f..c102e6e 100644 --- a/SrcMod/Shell/Shell.csproj +++ b/SrcMod/Shell/Shell.csproj @@ -2,7 +2,7 @@ Exe - net7.0 + net7.0-windows disable enable srcmod @@ -16,16 +16,6 @@ true - - embedded - 9999 - - - - embedded - 9999 - - @@ -36,4 +26,8 @@ + + + + diff --git a/SrcMod/Shell/Tools.cs b/SrcMod/Shell/Tools.cs index bd1c219..f1e0809 100644 --- a/SrcMod/Shell/Tools.cs +++ b/SrcMod/Shell/Tools.cs @@ -2,15 +2,27 @@ public static class Tools { - public static JsonSerializer Serializer { get; private set; } + public static JsonSerializer SerializerJson { get; private set; } + public static VkvSerializer SerializeVkv { get; private set; } static Tools() { - Serializer = JsonSerializer.Create(new() + SerializerJson = JsonSerializer.Create(new() { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }); + + SerializeVkv = new VkvSerializer(new() + { + closeWhenFinished = true, + indentSize = 4, + resetStreamPosition = false, + serializeProperties = true, + spacing = SpacingMode.DoubleTab, + useEscapeCodes = true, + useQuotes = true + }); } public static void DisplayWithPages(IEnumerable lines, ConsoleColor? color = null) diff --git a/SrcMod/SrcMod.sln b/SrcMod/SrcMod.sln new file mode 100644 index 0000000..22100d5 --- /dev/null +++ b/SrcMod/SrcMod.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shell", "Shell\Shell.csproj", "{6EC87235-F2A5-4313-A6DE-A4EE7CB7B341}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Valve.NET", "Valve.NET\Valve.NET.csproj", "{8FC96202-2F7E-4FBE-B08E-FCC38AA62D96}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6EC87235-F2A5-4313-A6DE-A4EE7CB7B341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EC87235-F2A5-4313-A6DE-A4EE7CB7B341}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EC87235-F2A5-4313-A6DE-A4EE7CB7B341}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EC87235-F2A5-4313-A6DE-A4EE7CB7B341}.Release|Any CPU.Build.0 = Release|Any CPU + {8FC96202-2F7E-4FBE-B08E-FCC38AA62D96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FC96202-2F7E-4FBE-B08E-FCC38AA62D96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FC96202-2F7E-4FBE-B08E-FCC38AA62D96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FC96202-2F7E-4FBE-B08E-FCC38AA62D96}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4E25AC28-DD70-4BB6-9083-07D6371DECCF} + EndGlobalSection +EndGlobal diff --git a/SrcMod/Valve.NET/Miscellaneous/GlobalUsings.cs b/SrcMod/Valve.NET/Miscellaneous/GlobalUsings.cs new file mode 100644 index 0000000..1e3c341 --- /dev/null +++ b/SrcMod/Valve.NET/Miscellaneous/GlobalUsings.cs @@ -0,0 +1,17 @@ +global using System; +global using System.Collections; +global using System.Collections.Generic; +global using System.ComponentModel; +global using System.Diagnostics; +global using System.Formats.Tar; +global using System.IO; +global using System.IO.Compression; +global using System.Linq; +global using System.Reflection; +global using System.Runtime.InteropServices; +global using System.Text; +global using System.Threading; +global using Valve; +global using Valve.Miscellaneous; +global using Valve.Vkv; +global using Valve.Vkv.ObjectModels; diff --git a/SrcMod/Valve.NET/Miscellaneous/TypeParsers.cs b/SrcMod/Valve.NET/Miscellaneous/TypeParsers.cs new file mode 100644 index 0000000..15a1bbd --- /dev/null +++ b/SrcMod/Valve.NET/Miscellaneous/TypeParsers.cs @@ -0,0 +1,74 @@ +namespace Valve.Miscellaneous; + +public static class TypeParsers +{ + public static bool CanParse(object? obj) => obj is not null && obj is bool or sbyte or byte or short or ushort + or int or uint or long or ulong or Int128 or UInt128 or nint or nuint or Half or float or double or decimal + or char or DateOnly or DateTime or DateTimeOffset or Guid or TimeOnly or TimeSpan; + public static object ParseAll(string msg) + { + if (TryParseBool(msg, out bool resBool)) return resBool; + else if (TryParse(msg, out sbyte int8)) return int8; + else if (TryParse(msg, out byte uInt8)) return uInt8; + else if (TryParse(msg, out short int16)) return int16; + else if (TryParse(msg, out ushort uInt16)) return uInt16; + else if (TryParse(msg, out int int32)) return int32; + else if (TryParse(msg, out uint uInt32)) return uInt32; + else if (TryParse(msg, out long int64)) return int64; + else if (TryParse(msg, out ulong uInt64)) return uInt64; + else if (TryParse(msg, out Int128 int128)) return int128; + else if (TryParse(msg, out UInt128 uInt128)) return uInt128; + else if (TryParse(msg, out nint intPtr)) return intPtr; + else if (TryParse(msg, out nuint uIntPtr)) return uIntPtr; + else if (TryParse(msg, out Half float16)) return float16; + else if (TryParse(msg, out float float32)) return float32; + else if (TryParse(msg, out double float64)) return float64; + else if (TryParse(msg, out decimal float128)) return float128; + else if (TryParse(msg, out char resChar)) return resChar; + else if (TryParse(msg, out DateOnly dateOnly)) return dateOnly; + else if (TryParse(msg, out DateTime dateTime)) return dateTime; + else if (TryParse(msg, out DateTimeOffset dateTimeOffset)) return dateTimeOffset; + else if (TryParse(msg, out Guid guid)) return guid; + else if (TryParse(msg, out TimeOnly timeOnly)) return timeOnly; + else if (TryParse(msg, out TimeSpan timeSpan)) return timeSpan; + else return msg; + } + + public static bool TryParseBool(string msg, out bool result) + { + string trimmed = msg.Trim().ToLower(); + + string[] trues = new string[] + { + "t", + "true", + "1", + "y", + "yes" + }, + falses = new string[] + { + "f", + "false", + "0", + "n", + "no" + }; + + foreach (string t in trues) if (trimmed == t) + { + result = true; + return true; + } + foreach (string f in falses) if (trimmed == f) + { + result = false; + return true; + } + + result = false; + return false; + } + public static bool TryParse(string msg, out T? result) where T : IParsable + => T.TryParse(msg, null, out result); +} diff --git a/SrcMod/Valve.NET/Valve.NET.csproj b/SrcMod/Valve.NET/Valve.NET.csproj new file mode 100644 index 0000000..cfa86ac --- /dev/null +++ b/SrcMod/Valve.NET/Valve.NET.csproj @@ -0,0 +1,17 @@ + + + + Library + net7.0 + disable + enable + valve.net + Valve + ../Compiled/Valve.NET + Valve.NET + That_One_Nerd + false + true + + + diff --git a/SrcMod/Valve.NET/Vkv/ObjectModels/IVkvConvertible.cs b/SrcMod/Valve.NET/Vkv/ObjectModels/IVkvConvertible.cs new file mode 100644 index 0000000..c52e3a0 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/ObjectModels/IVkvConvertible.cs @@ -0,0 +1,6 @@ +namespace Valve.Vkv.ObjectModels; + +public interface IVkvConvertible +{ + public VkvNode ToNodeTree(); +} \ No newline at end of file diff --git a/SrcMod/Valve.NET/Vkv/ObjectModels/VkvIgnoreAttribute.cs b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvIgnoreAttribute.cs new file mode 100644 index 0000000..6872087 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvIgnoreAttribute.cs @@ -0,0 +1,4 @@ +namespace Valve.Vkv.ObjectModels; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = true)] +public class VkvIgnoreAttribute : Attribute { } diff --git a/SrcMod/Valve.NET/Vkv/ObjectModels/VkvKeyNameAttribute.cs b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvKeyNameAttribute.cs new file mode 100644 index 0000000..4c54a97 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvKeyNameAttribute.cs @@ -0,0 +1,9 @@ +namespace Valve.Vkv.ObjectModels; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public class VkvKeyNameAttribute : Attribute +{ + public readonly string name; + + public VkvKeyNameAttribute(string name) => this.name = name; +} diff --git a/SrcMod/Valve.NET/Vkv/ObjectModels/VkvSerializationException.cs b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvSerializationException.cs new file mode 100644 index 0000000..4714ce8 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/ObjectModels/VkvSerializationException.cs @@ -0,0 +1,8 @@ +namespace Valve.Vkv.ObjectModels; + +public class VkvSerializationException : Exception +{ + public VkvSerializationException() : base() { } + public VkvSerializationException(string message) : base(message) { } + public VkvSerializationException(string message, Exception inner) : base(message, inner) { } +} diff --git a/SrcMod/Valve.NET/Vkv/SpacingMode.cs b/SrcMod/Valve.NET/Vkv/SpacingMode.cs new file mode 100644 index 0000000..6f5064b --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/SpacingMode.cs @@ -0,0 +1,8 @@ +namespace Valve.Vkv; + +public enum SpacingMode +{ + SingleSpace = 0, + IndentSizeSpacing, + DoubleTab, +} diff --git a/SrcMod/Valve.NET/Vkv/VkvConvert.cs b/SrcMod/Valve.NET/Vkv/VkvConvert.cs new file mode 100644 index 0000000..76c6c25 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvConvert.cs @@ -0,0 +1,449 @@ +namespace Valve.Vkv; + +public static class VkvConvert +{ + private static readonly Dictionary p_escapeCodes = new() + { + { "\\", @"\\" }, // This must be first. + { "\'", @"\'" }, + { "\"", @"\""" }, + { "\0", @"\0" }, + { "\a", @"\a" }, + { "\b", @"\b" }, + { "\f", @"\f" }, + { "\n", @"\n" }, + { "\r", @"\r" }, + { "\t", @"\t" }, + { "\v", @"\v" } + }; + + #region DeserializeNode + public static VkvNode? DeserializeNode(StreamReader reader) => + DeserializeNode(reader, VkvOptions.Default); + public static VkvNode? DeserializeNode(StreamReader reader, VkvOptions options) + { + try + { + return DeserializeNode(reader, options, out _, null); + } + catch + { + if (!options.noExceptions) throw; + return null; + } + } + + private static VkvNode? DeserializeNode(StreamReader reader, VkvOptions options, out string name, + string? first) + { + string? header = first ?? (reader.ReadLine()?.Trim()); + if (header is null || string.IsNullOrEmpty(header)) + { + name = string.Empty; + return null; + } + + string[] parts = SplitContent(header, options); + if (parts.Length > 2) throw new VkvSerializationException("Too many values in node."); + + VkvNode node; + + name = DeserializeString(parts[0], options); + if (parts.Length == 2) + { + string value = DeserializeString(parts[1], options); + node = new VkvSingleNode(value); + } + else + { + string? next = reader.ReadLine()?.Trim(); + if (next is null) throw new VkvSerializationException("Expected starting '{', found end-of-file."); + else if (next != "{") throw new VkvSerializationException($"Expected starting '{{', found \"{next}\"."); + VkvTreeNode tree = new(); + string? current; + while ((current = reader.ReadLine()?.Trim()) is not null) + { + if (current == "}") break; + VkvNode? output = DeserializeNode(reader, options, out string subName, current); + tree[subName] = output; + } + if (current is null) throw new VkvSerializationException("Reached end-of-file while deserializing group."); + node = tree; + } + + return node; + } + + private static string DeserializeString(string content, VkvOptions options) + { + if (options.useQuotes) + { + if (!content.StartsWith('\"') || !content.EndsWith('\"')) + throw new VkvSerializationException("No quotes found around content."); + content = content[1..^1]; + } + if (options.useEscapeCodes) + { + foreach (KeyValuePair escapeCode in p_escapeCodes.Reverse()) + content = content.Replace(escapeCode.Value, escapeCode.Key); + } + return content; + } + + private static string[] SplitContent(string content, VkvOptions options) + { + content = content.Replace('\t', ' '); + if (options.useQuotes) + { + List values = new(); + string current = string.Empty; + bool inQuote = false; + for (int i = 0; i < content.Length; i++) + { + char c = content[i]; + if (c == '\"' && !(i > 0 && content[i - 1] == '\\')) inQuote = !inQuote; + + if (c == ' ' && !inQuote) + { + if (!string.IsNullOrEmpty(current)) values.Add(current); + current = string.Empty; + } + else current += c; + } + if (inQuote) throw new VkvSerializationException("Reached end-of-line while inside quotations."); + if (!string.IsNullOrEmpty(current)) values.Add(current); + return values.ToArray(); + } + else return content.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + #endregion + + #region FromNodeTree + public static T? FromNodeTree(VkvNode? node, VkvOptions options) => (T?)FromNodeTree(typeof(T), node, options); + public static object? FromNodeTree(Type outputType, VkvNode? node, VkvOptions options) + { + try + { + if (node is null) return null; + + if (node is VkvSingleNode single) return FromSingleNode(outputType, single); + else if (node is VkvTreeNode tree) return FromTreeNode(outputType, tree, options); + else throw new VkvSerializationException("Unknown VKV node type."); + } + catch + { + if (!options.noExceptions) throw; + return null; + } + } + + private static object? FromSingleNode(Type outputType, VkvSingleNode node) + { + object? value = node.value; + if (value is null) return null; + else if (value is string str) + { + value = TypeParsers.ParseAll(str); + if (value is string still && outputType.IsEnum) + { + if (Enum.TryParse(outputType, still, true, out object? res) && res is not null) + value = res; + } + } + return Convert.ChangeType(value, outputType); + } + + private static object? FromTreeNode(Type outputType, VkvTreeNode node, VkvOptions options) + { + if (outputType.IsArray) + return FromTreeNodeArray(outputType, node, options); + + else if (outputType.GetInterface("IList") is not null) + return FromTreeNodeList(outputType, node, options); + + else if (outputType.GetInterface("IDictionary") is not null) + return FromTreeNodeDictionary(outputType, node, options); + + object? instance = Activator.CreateInstance(outputType); + if (instance is null) return null; + + IEnumerable validFields = from field in outputType.GetFields() + let isPublic = field.IsPublic + let isStatic = field.IsStatic + let isIgnored = field.CustomAttributes.Any(x => + x.AttributeType == typeof(VkvIgnoreAttribute)) + let isConst = field.IsLiteral + where isPublic && !isStatic && !isIgnored && !isConst + select field; + + IEnumerable validProperties; + if (options.serializeProperties) + { + validProperties = from prop in outputType.GetProperties() + let canSet = prop.SetMethod is not null + let isPublic = canSet && prop.SetMethod!.IsPublic + let isStatic = canSet && prop.SetMethod!.IsStatic + let isIgnored = prop.CustomAttributes.Any(x => + x.AttributeType == typeof(VkvIgnoreAttribute)) + where canSet && isPublic && !isStatic && !isIgnored + select prop; + } + else validProperties = Array.Empty(); + + foreach (FieldInfo field in validFields) + { + VkvKeyNameAttribute? namingAttribute = field.GetCustomAttribute(); + + VkvNode? subNode = node[namingAttribute?.name ?? field.Name]; + if (subNode is null) continue; + + object? result = FromNodeTree(field.FieldType, subNode, options); + if (result is null) continue; + field.SetValue(instance, result); + } + foreach (PropertyInfo prop in validProperties) + { + VkvKeyNameAttribute? namingAttribute = prop.GetCustomAttribute(); + + VkvNode? subNode = node[namingAttribute?.name ?? prop.Name]; + if (subNode is null) continue; + + object? result = FromNodeTree(prop.PropertyType, subNode, options); + if (result is null) continue; + prop.SetValue(instance, result); + } + + return instance; + } + + private static object? FromTreeNodeArray(Type outputType, VkvTreeNode node, VkvOptions options) + { + Type elementType = outputType.GetElementType()!; + Array array = Array.CreateInstance(elementType, node.SubNodeCount); + + int index = 0; + foreach (KeyValuePair subNode in node) + { + string indexStr = index.ToString(); + if (subNode.Key != indexStr) throw new VkvSerializationException($"Cannot convert node tree to array."); + array.SetValue(FromNodeTree(elementType, subNode.Value, options), index); + index++; + } + return array; + } + + private static object? FromTreeNodeList(Type outputType, VkvTreeNode node, VkvOptions options) + { + IList? instance = (IList?)Activator.CreateInstance(outputType); + if (instance is null) return null; + + // There is no guarentee that the first type argument corresponds to the element type, + // but as far as I know there isn't a better way. + Type elementType = outputType.IsGenericType ? outputType.GenericTypeArguments[0] : typeof(object); + + int index = 0; + foreach (KeyValuePair subNode in node) + { + string indexStr = index.ToString(); + if (subNode.Key != indexStr) throw new VkvSerializationException($"Cannot convert node tree to array."); + instance.Add(FromNodeTree(elementType, subNode.Value, options)); + index++; + } + return instance; + } + + private static object? FromTreeNodeDictionary(Type outputType, VkvTreeNode node, VkvOptions options) + { + IDictionary? instance = (IDictionary?)Activator.CreateInstance(outputType); + if (instance is null) return null; + + // There is no guarentee that the first and second type arguments represent the + // key and value types, but as far as I know there isn't a better way. + bool canUseGenerics = outputType.GenericTypeArguments.Length >= 2; + Type keyType = canUseGenerics ? outputType.GenericTypeArguments[0] : typeof(object), + valueType = canUseGenerics ? outputType.GenericTypeArguments[1] : typeof(object); + + foreach (KeyValuePair subNode in node) + { + object key = TypeParsers.ParseAll(subNode.Key); + if (key is string still && keyType.IsEnum) + { + if (Enum.TryParse(keyType, still, true, out object? res) && res is not null) + key = res; + } + key = Convert.ChangeType(key, keyType); + + object? value = FromNodeTree(valueType, subNode.Value, options); + + instance.Add(key, value); + } + return instance; + } + #endregion + + #region SerializeNode + public static void SerializeNode(StreamWriter writer, VkvNode? node, string name, + VkvOptions options) + { + try + { + SerializeNode(writer, node, name, options, 0); + } + catch + { + if (!options.noExceptions) throw; + } + } + public static void SerializeNode(StreamWriter writer, VkvNode? node, string name) => + SerializeNode(writer, node, name, VkvOptions.Default); + + private static void SerializeNode(StreamWriter writer, VkvNode? node, string name, + VkvOptions options, int indentLevel) + { + if (node is null) return; + else if (node is VkvSingleNode single) SerializeSingleNode(writer, single, name, options, indentLevel); + else if (node is VkvTreeNode tree) SerializeTreeNode(writer, tree, name, options, indentLevel); + else throw new("Unknown node type."); + } + + private static void SerializeSingleNode(StreamWriter writer, VkvSingleNode node, string name, + VkvOptions options, int indentLevel) + { + string? serializedValue = SerializeObject(node.value); + if (serializedValue is null) return; + + writer.Write(new string(' ', indentLevel)); + writer.Write(SerializeString(name, options)); + + switch (options.spacing) + { + case SpacingMode.SingleSpace: writer.Write(' '); + break; + + case SpacingMode.IndentSizeSpacing: writer.Write(new string(' ', options.indentSize)); + break; + + case SpacingMode.DoubleTab: writer.Write("\t\t"); + break; + + default: throw new VkvSerializationException($"Unknown spacing mode \"{options.spacing}\"."); + } + + serializedValue = SerializeString(serializedValue, options); + writer.WriteLine(serializedValue); + } + private static void SerializeTreeNode(StreamWriter writer, VkvTreeNode node, string name, + VkvOptions options, int indentLevel) + { + if (node.SubNodeCount <= 0) return; + + writer.Write(new string(' ', indentLevel)); + writer.WriteLine(SerializeString(name, options)); + writer.WriteLine(new string(' ', indentLevel) + '{'); + + foreach (KeyValuePair subNode in node) + SerializeNode(writer, subNode.Value, subNode.Key, options, indentLevel + options.indentSize); + + writer.WriteLine(new string(' ', indentLevel) + '}'); + } + + private static string? SerializeObject(object? obj) + { + if (obj is null) return null; + return obj.ToString() ?? string.Empty; + } + + private static string SerializeString(string content, VkvOptions options) + { + if (options.useEscapeCodes) + { + foreach (KeyValuePair escapeCode in p_escapeCodes) + content = content.Replace(escapeCode.Key, escapeCode.Value); + } + if (options.useQuotes) content = $"\"{content}\""; + return content; + } + #endregion + + #region ToNodeTree + public static VkvNode? ToNodeTree(object? obj) => ToNodeTree(obj, VkvOptions.Default); + public static VkvNode? ToNodeTree(object? obj, VkvOptions options) + { + try + { + if (obj is null) return null; + Type type = obj.GetType(); + + if (type.IsPrimitive || TypeParsers.CanParse(obj)) return new VkvSingleNode(obj); + else if (type.IsPointer) throw new("Cannot serialize a pointer."); + + VkvTreeNode tree = new(); + + if (obj is IVkvConvertible vkv) return vkv.ToNodeTree(); + else if (obj is IDictionary dictionary) + { + object[] keys = new object[dictionary.Count], + values = new object[dictionary.Count]; + dictionary.Keys.CopyTo(keys, 0); + dictionary.Values.CopyTo(values, 0); + for (int i = 0; i < dictionary.Count; i++) + { + tree[SerializeObject(keys.GetValue(i))!] = ToNodeTree(values.GetValue(i), options); + } + return tree; + } + else if (obj is ICollection enumerable) + { + int index = 0; + foreach (object item in enumerable) + { + tree[SerializeObject(index)!] = ToNodeTree(item, options); + index++; + } + return tree; + } + + IEnumerable validFields = from field in type.GetFields() + let isPublic = field.IsPublic + let isStatic = field.IsStatic + let isIgnored = field.CustomAttributes.Any(x => + x.AttributeType == typeof(VkvIgnoreAttribute)) + let isConst = field.IsLiteral + where isPublic && !isStatic && !isIgnored && !isConst + select field; + + IEnumerable validProperties; + if (options.serializeProperties) + { + validProperties = from prop in type.GetProperties() + let canGet = prop.GetMethod is not null + let isPublic = canGet && prop.GetMethod!.IsPublic + let isStatic = canGet && prop.GetMethod!.IsStatic + let isIgnored = prop.CustomAttributes.Any(x => + x.AttributeType == typeof(VkvIgnoreAttribute)) + where canGet && isPublic && !isStatic && !isIgnored + select prop; + } + else validProperties = Array.Empty(); + + foreach (FieldInfo field in validFields) + { + VkvKeyNameAttribute? namingAttribute = field.GetCustomAttribute(); + tree[namingAttribute?.name ?? field.Name] = ToNodeTree(field.GetValue(obj), options); + } + foreach (PropertyInfo prop in validProperties) + { + VkvKeyNameAttribute? namingAttribute = prop.GetCustomAttribute(); + tree[namingAttribute?.name ?? prop.Name] = ToNodeTree(prop.GetValue(obj), options); + } + + return tree; + } + catch + { + if (!options.noExceptions) throw; + return null; + } + } + #endregion +} diff --git a/SrcMod/Valve.NET/Vkv/VkvNode.cs b/SrcMod/Valve.NET/Vkv/VkvNode.cs new file mode 100644 index 0000000..70fbcd9 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvNode.cs @@ -0,0 +1,3 @@ +namespace Valve.Vkv; + +public abstract class VkvNode { } diff --git a/SrcMod/Valve.NET/Vkv/VkvOptions.cs b/SrcMod/Valve.NET/Vkv/VkvOptions.cs new file mode 100644 index 0000000..ceb461d --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvOptions.cs @@ -0,0 +1,27 @@ +namespace Valve.Vkv; + +public record class VkvOptions +{ + public static VkvOptions Default => new(); + + public bool closeWhenFinished; + public int indentSize; + public bool noExceptions; + public bool resetStreamPosition; + public bool serializeProperties; + public SpacingMode spacing; + public bool useEscapeCodes; + public bool useQuotes; + + public VkvOptions() + { + closeWhenFinished = true; + indentSize = 4; + noExceptions = false; + resetStreamPosition = false; + serializeProperties = true; + spacing = SpacingMode.DoubleTab; + useEscapeCodes = false; + useQuotes = false; + } +} diff --git a/SrcMod/Valve.NET/Vkv/VkvSerializer.cs b/SrcMod/Valve.NET/Vkv/VkvSerializer.cs new file mode 100644 index 0000000..9a18935 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvSerializer.cs @@ -0,0 +1,57 @@ +namespace Valve.Vkv; + +public class VkvSerializer +{ + public VkvOptions Options => p_options; + + private readonly VkvOptions p_options; + + public VkvSerializer() : this(VkvOptions.Default) { } + public VkvSerializer(VkvOptions options) + { + p_options = options; + } + + public VkvNode? Deserialize(Stream stream) + { + long pos = stream.Position; + StreamReader reader = new(stream, leaveOpen: !p_options.closeWhenFinished); + try + { + VkvNode? result = VkvConvert.DeserializeNode(reader, p_options); + reader.Close(); + if (!p_options.closeWhenFinished && p_options.resetStreamPosition) stream.Seek(pos, SeekOrigin.Begin); + return result; + } + finally + { + reader.Close(); + if (!p_options.closeWhenFinished && p_options.resetStreamPosition) stream.Seek(pos, SeekOrigin.Begin); + } + } + public T? Deserialize(Stream stream) + { + VkvNode? result = Deserialize(stream); + return VkvConvert.FromNodeTree(result, p_options); + } + public object? Deserialize(Type outputType, Stream stream) + { + VkvNode? result = Deserialize(stream); + return VkvConvert.FromNodeTree(outputType, result, p_options); + } + + public void Serialize(Stream stream, object? value, string parentNodeName) + { + VkvNode? nodeTree = VkvConvert.ToNodeTree(value, p_options); + Serialize(stream, nodeTree, parentNodeName); + } + public void Serialize(Stream stream, VkvNode? parentNode, string parentNodeName) + { + long pos = stream.Position; + StreamWriter writer = new(stream, leaveOpen: !p_options.closeWhenFinished); + VkvConvert.SerializeNode(writer, parentNode, parentNodeName, p_options); + writer.Close(); + + if (!p_options.closeWhenFinished && p_options.resetStreamPosition) stream.Seek(pos, SeekOrigin.Begin); + } +} diff --git a/SrcMod/Valve.NET/Vkv/VkvSingleNode.cs b/SrcMod/Valve.NET/Vkv/VkvSingleNode.cs new file mode 100644 index 0000000..9b77410 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvSingleNode.cs @@ -0,0 +1,11 @@ +namespace Valve.Vkv; + +public class VkvSingleNode : VkvNode +{ + public object? value; + + public VkvSingleNode(object? value = null) : base() + { + this.value = value; + } +} diff --git a/SrcMod/Valve.NET/Vkv/VkvTreeNode.cs b/SrcMod/Valve.NET/Vkv/VkvTreeNode.cs new file mode 100644 index 0000000..6073922 --- /dev/null +++ b/SrcMod/Valve.NET/Vkv/VkvTreeNode.cs @@ -0,0 +1,45 @@ +namespace Valve.Vkv; + +public class VkvTreeNode : VkvNode, IEnumerable> +{ + public int SubNodeCount => p_subNodes.Count; + + private readonly Dictionary p_subNodes; + + public VkvTreeNode(Dictionary? subNodes = null) : base() + { + p_subNodes = subNodes ?? new(); + } + + public VkvNode? this[string key] + { + get + { + if (p_subNodes.TryGetValue(key, out VkvNode? value)) return value; + else return null; + } + set + { + if (p_subNodes.ContainsKey(key)) p_subNodes[key] = value; + else p_subNodes.Add(key, value); + } + } + public VkvNode? this[int index] + { + get + { + if (p_subNodes.Count >= index || index < 0) return null; + return p_subNodes.Values.ElementAt(index); + } + set + { + if (p_subNodes.Count >= index || index < 0) throw new IndexOutOfRangeException(); + p_subNodes[p_subNodes.Keys.ElementAt(index)] = value; + } + } + + public void Add(string key, VkvNode? value) => this[key] = value; + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public IEnumerator> GetEnumerator() => p_subNodes.GetEnumerator(); +}