using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; namespace NitroxModel.Serialization; public abstract class NitroxConfig where T : NitroxConfig, new() { private static readonly Dictionary unserializedMembersWarnOnceCache = []; private static readonly Dictionary typeCache = []; private readonly char[] newlineChars = Environment.NewLine.ToCharArray(); private readonly object locker = new(); public abstract string FileName { get; } public static T Load(string saveDir) { T config = new(); config.Deserialize(saveDir); return config; } public void Deserialize(string saveDir) { if (!File.Exists(Path.Combine(saveDir, FileName))) { return; } lock (locker) { Type type = GetType(); Dictionary typeCachedDict = GetTypeCacheDictionary(); using StreamReader reader = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Open, FileAccess.Read, FileShare.Read), Encoding.UTF8); HashSet unserializedMembers = new(typeCachedDict.Values); char[] lineSeparator = { '=' }; int lineNum = 0; string readLine; while ((readLine = reader.ReadLine()) != null) { lineNum++; if (readLine.Length < 1 || readLine[0] == '#') { continue; } if (readLine.Contains('=')) { string[] keyValuePair = readLine.Split(lineSeparator, 2); // Ignore case for property names in file. if (!typeCachedDict.TryGetValue(keyValuePair[0].ToLowerInvariant(), out MemberInfo member)) { Log.Warn($"Property or field {keyValuePair[0]} does not exist on type {type.FullName}!"); continue; } unserializedMembers.Remove(member); // This member was serialized in the file if (!NitroxConfig.SetMemberValue(this, member, keyValuePair[1])) { (Type type, object value) logData = member switch { FieldInfo field => (field.FieldType, field.GetValue(this)), PropertyInfo prop => (prop.PropertyType, prop.GetValue(this)), _ => (typeof(string), "") }; Log.Warn($@"Property ""({logData.type.Name}) {member.Name}"" has an invalid value {NitroxConfig.StringifyValue(keyValuePair[1])} on line {lineNum}. Using default value: {NitroxConfig.StringifyValue(logData.value)}"); } } else { Log.Error($"Incorrect format detected on line {lineNum} in {Path.GetFullPath(Path.Combine(saveDir, FileName))}:{Environment.NewLine}{readLine}"); } } if (unserializedMembers.Count != 0) { string[] unserializedProps = unserializedMembers .Select(m => { object value = null; if (m is FieldInfo field) { value = field.GetValue(this); } else if (m is PropertyInfo prop) { value = prop.GetValue(this); } if (unserializedMembersWarnOnceCache.TryGetValue(m.Name, out object cachedValue)) { if (Equals(value, cachedValue)) { return null; } } unserializedMembersWarnOnceCache[m.Name] = value; return $" - {m.Name}: {value}"; }) .Where(i => i != null) .ToArray(); if (unserializedProps.Length > 0) { Log.Warn($"{FileName} is using default values for the missing properties:{Environment.NewLine}{string.Join(Environment.NewLine, unserializedProps)}"); } } } } public void Serialize(string saveDir) { lock (locker) { Type type = GetType(); Dictionary typeCachedDict = GetTypeCacheDictionary(); try { Directory.CreateDirectory(saveDir); using StreamWriter stream = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Create, FileAccess.Write), Encoding.UTF8); WritePropertyDescription(type, stream); foreach (string name in typeCachedDict.Keys) { MemberInfo member = typeCachedDict[name]; FieldInfo field = member as FieldInfo; if (field != null) { WritePropertyDescription(member, stream); NitroxConfig.WriteProperty(field, field.GetValue(this), stream); } PropertyInfo property = member as PropertyInfo; if (property != null) { WritePropertyDescription(member, stream); NitroxConfig.WriteProperty(property, property.GetValue(this), stream); } } } catch (UnauthorizedAccessException) { Log.Error($"Config file {FileName} exists but is a hidden file and cannot be modified, config file will not be updated. Please make file accessible"); } } } /// /// Ensures updates are properly persisted to the backing config file without overwriting user edits. /// public UpdateDiposable Update(string saveDir) { return new UpdateDiposable(this, saveDir); } private static Dictionary GetTypeCacheDictionary() { Type type = typeof(T); if (typeCache.Count == 0) { IEnumerable members = type.GetFields() .Where(f => f.Attributes != FieldAttributes.NotSerialized) .Concat(type.GetProperties() .Where(p => p.CanWrite) .Cast()); try { foreach (MemberInfo member in members) { typeCache.Add(member.Name.ToLowerInvariant(), member); } } catch (ArgumentException e) { Log.Error(e, $"Type {type.FullName} has properties that require case-sensitivity to be unique which is unsuitable for .properties format."); throw; } } return typeCache; } private static string StringifyValue(object value) => value switch { string _ => $@"""{value}""", null => @"""""", _ => value.ToString() }; private static bool SetMemberValue(NitroxConfig instance, MemberInfo member, string valueFromFile) { object ConvertFromStringOrDefault(Type typeOfValue, out bool isDefault, object defaultValue = default) { try { object newValue = TypeDescriptor.GetConverter(typeOfValue).ConvertFrom(null!, CultureInfo.InvariantCulture, valueFromFile); isDefault = false; return newValue; } catch (Exception) { isDefault = true; return defaultValue; } } bool usedDefault; switch (member) { case FieldInfo field: field.SetValue(instance, ConvertFromStringOrDefault(field.FieldType, out usedDefault, field.GetValue(instance))); return !usedDefault; case PropertyInfo prop: prop.SetValue(instance, ConvertFromStringOrDefault(prop.PropertyType, out usedDefault, prop.GetValue(instance))); return !usedDefault; default: throw new Exception($"Serialized member must be field or property: {member}."); } } private static void WriteProperty(TMember member, object value, StreamWriter stream) where TMember : MemberInfo { stream.Write(member.Name); stream.Write('='); stream.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture)); } private void WritePropertyDescription(MemberInfo member, StreamWriter stream) { PropertyDescriptionAttribute attribute = member.GetCustomAttribute(); if (attribute != null) { foreach (string line in attribute.Description.Split(newlineChars)) { stream.Write("# "); stream.WriteLine(line); } } } public readonly struct UpdateDiposable : IDisposable { private string SaveDir { get; } private NitroxConfig Config { get; } public UpdateDiposable(NitroxConfig config, string saveDir) { config.Deserialize(saveDir); SaveDir = saveDir; Config = config; } public void Dispose() => Config.Serialize(SaveDir); } }