using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using Nett; using Robust.Shared.Log; using Robust.Shared.Utility; namespace Robust.Shared.Configuration { /// /// Stores and manages global configuration variables. /// [Virtual] internal class ConfigurationManager : IConfigurationManagerInternal { private const char TABLE_DELIMITER = '.'; protected readonly Dictionary _configVars = new(); private string? _configFile; protected bool _isServer; /// /// Constructs a new ConfigurationManager. /// public ConfigurationManager() { } public void Initialize(bool isServer) { _isServer = isServer; } public virtual void Shutdown() { _configVars.Clear(); _configFile = null; } /// public void LoadFromFile(string configFile) { try { var tblRoot = Toml.ReadFile(configFile); ProcessTomlObject(tblRoot); _configFile = configFile; Logger.InfoS("cfg", $"Configuration Loaded from '{Path.GetFullPath(configFile)}'"); } catch (Exception e) { Logger.WarningS("cfg", "Unable to load configuration file:\n{0}", e); } } public void SetSaveFile(string configFile) { _configFile = configFile; } /// /// A recursive function that walks over the config tree, transforming all key nodes into CVars. /// /// The root table of the TOML document. /// For internal use only, the current path to the node. private void ProcessTomlObject(TomlObject obj, string tablePath = "") { if (obj is TomlTable table) // this is a table { foreach (var kvTml in table) { string newPath; if ((kvTml.Value is TomlTable)) newPath = tablePath + kvTml.Key + TABLE_DELIMITER; else newPath = tablePath + kvTml.Key; ProcessTomlObject(kvTml.Value, newPath); } } else // this is a key, add CVar { // if the CVar has already been registered var tomlValue = TypeConvert(obj); if (_configVars.TryGetValue(tablePath, out var cfgVar)) { // overwrite the value with the saved one cfgVar.Value = tomlValue; InvokeValueChanged(cfgVar, cfgVar.Value); } else { //or add another unregistered CVar //Note: the defaultValue is arbitrarily 0, it will get overwritten when the cvar is registered. cfgVar = new ConfigVar(tablePath, 0, CVar.NONE) {Value = tomlValue}; _configVars.Add(tablePath, cfgVar); } cfgVar.ConfigModified = true; } } /// public void SaveToFile() { if (_configFile == null) { Logger.WarningS("cfg", "Cannot save the config file, because one was never loaded."); return; } try { var tblRoot = Toml.Create(); foreach (var (name, cVar) in _configVars) { var value = cVar.Value; if (value == null && cVar.Registered) { value = cVar.DefaultValue; } if (value == null) { Logger.ErrorS("cfg", $"CVar {name} has no value or default value, was the default value registered as null?"); continue; } // Don't write if Archive flag is not set. // Don't write if the cVar is the default value. if (!cVar.ConfigModified && (cVar.Flags & CVar.ARCHIVE) == 0 || value.Equals(cVar.DefaultValue)) { continue; } var keyIndex = name.LastIndexOf(TABLE_DELIMITER); var tblPath = name.Substring(0, keyIndex).Split(TABLE_DELIMITER); var keyName = name.Substring(keyIndex + 1); // locate the Table in the config tree var table = tblRoot; foreach (var curTblName in tblPath) { if (!table.TryGetValue(curTblName, out TomlObject tblObject)) { tblObject = table.Add(curTblName, new Dictionary()).Added; } table = tblObject as TomlTable ?? throw new InvalidConfigurationException( $"[CFG] Object {curTblName} is being used like a table, but it is a {tblObject}. Are your CVar names formed properly?"); } //runtime unboxing, either this or generic hell... ¯\_(ツ)_/¯ switch (value) { case Enum val: table.Add(keyName, (int) (object) val); // asserts Enum value != (ulong || long) break; case int val: table.Add(keyName, val); break; case long val: table.Add(keyName, val); break; case bool val: table.Add(keyName, val); break; case string val: table.Add(keyName, val); break; case float val: table.Add(keyName, val); break; case double val: table.Add(keyName, val); break; default: Logger.WarningS("cfg", $"Cannot serialize '{name}', unsupported type."); break; } } Toml.WriteFile(tblRoot, _configFile); Logger.InfoS("cfg", $"config saved to '{_configFile}'."); } catch (Exception e) { Logger.WarningS("cfg", $"Cannot save the config file '{_configFile}'.\n {e}"); } } public void RegisterCVar(string name, T defaultValue, CVar flags = CVar.NONE, Action? onValueChanged = null) where T : notnull { RegisterCVar(name, typeof(T), defaultValue, flags); if (onValueChanged != null) OnValueChanged(name, onValueChanged); } private void RegisterCVar(string name, Type type, object defaultValue, CVar flags) { DebugTools.Assert(!type.IsEnum || type.GetEnumUnderlyingType() == typeof(int), $"{name}: Enum cvars must have int as underlying type."); var only = _isServer ? CVar.CLIENTONLY : CVar.SERVERONLY; if ((flags & only) != 0) { // Ignored on this side. return; } if (_configVars.TryGetValue(name, out var cVar)) { if (cVar.Registered) Logger.ErrorS("cfg", $"The variable '{name}' has already been registered."); cVar.DefaultValue = defaultValue; cVar.Flags = flags; cVar.Registered = true; if (cVar.OverrideValue != null) { cVar.OverrideValueParsed = ParseOverrideValue(cVar.OverrideValue, type); } return; } _configVars.Add(name, new ConfigVar(name, defaultValue, flags) { Registered = true, Value = defaultValue, }); } public void OnValueChanged(CVarDef cVar, Action onValueChanged, bool invokeImmediately = false) where T : notnull { OnValueChanged(cVar.Name, onValueChanged, invokeImmediately); } public void OnValueChanged(string name, Action onValueChanged, bool invokeImmediately = false) where T : notnull { var reg = _configVars[name]; var exDel = (Action?) reg.ValueChanged; exDel += onValueChanged; reg.ValueChanged = exDel; reg.ValueChangedInvoker ??= (del, v) => ((Action) del)((T) v); if (invokeImmediately) { onValueChanged(GetCVar(name)); } } public void UnsubValueChanged(CVarDef cVar, Action onValueChanged) where T : notnull { UnsubValueChanged(cVar.Name, onValueChanged); } public void UnsubValueChanged(string name, Action onValueChanged) where T : notnull { var reg = _configVars[name]; var exDel = (Action?) reg.ValueChanged; exDel -= onValueChanged; reg.ValueChanged = exDel; } public void LoadCVarsFromAssembly(Assembly assembly) { foreach (var defField in assembly .GetTypes() .Where(p => Attribute.IsDefined(p, typeof(CVarDefsAttribute))) .SelectMany(p => p.GetFields(BindingFlags.Public | BindingFlags.Static))) { var fieldType = defField.FieldType; if (!fieldType.IsGenericType || fieldType.GetGenericTypeDefinition() != typeof(CVarDef<>)) { continue; } var type = fieldType.GetGenericArguments()[0]; if (!defField.IsInitOnly) { throw new InvalidOperationException( $"Found CVarDef '{defField.Name}' on '{defField.DeclaringType?.FullName}' that is not readonly. Please mark it as readonly."); } var def = (CVarDef?) defField.GetValue(null); if (def == null) { throw new InvalidOperationException( $"CVarDef '{defField.Name}' on '{defField.DeclaringType?.FullName}' is null."); } RegisterCVar(def.Name, type, def.DefaultValue, def.Flags); } } /// public bool IsCVarRegistered(string name) { return _configVars.TryGetValue(name, out var cVar) && cVar.Registered; } /// public IEnumerable GetRegisteredCVars() { return _configVars.Select(p => p.Key); } /// public virtual void SetCVar(string name, object value) { SetCVarInternal(name, value); } private void SetCVarInternal(string name, object value) { //TODO: Make flags work, required non-derpy net system. if (_configVars.TryGetValue(name, out var cVar) && cVar.Registered) { if (!Equals(cVar.OverrideValueParsed ?? cVar.Value, value)) { // Setting an overriden var just turns off the override, basically. cVar.OverrideValue = null; cVar.OverrideValueParsed = null; cVar.Value = value; InvokeValueChanged(cVar, value); } } else throw new InvalidConfigurationException($"Trying to set unregistered variable '{name}'"); } public void SetCVar(CVarDef def, T value) where T : notnull { SetCVar(def.Name, value); } /// public T GetCVar(string name) { if (_configVars.TryGetValue(name, out var cVar) && cVar.Registered) //TODO: Make flags work, required non-derpy net system. return (T) (GetConfigVarValue(cVar))!; throw new InvalidConfigurationException($"Trying to get unregistered variable '{name}'"); } public T GetCVar(CVarDef def) where T : notnull { return GetCVar(def.Name); } public Type GetCVarType(string name) { if (!_configVars.TryGetValue(name, out var cVar) || !cVar.Registered) { throw new InvalidConfigurationException($"Trying to get type of unregistered variable '{name}'"); } // If it's null it's a string, since the rest is primitives which aren't null. return cVar.Value?.GetType() ?? typeof(string); } protected static object GetConfigVarValue(ConfigVar cVar) { return cVar.OverrideValueParsed ?? cVar.Value ?? cVar.DefaultValue; } public void OverrideConVars(IEnumerable<(string key, string value)> cVars) { foreach (var (key, value) in cVars) { if (_configVars.TryGetValue(key, out var cfgVar)) { cfgVar.OverrideValue = value; if (cfgVar.Registered) { cfgVar.OverrideValueParsed = ParseOverrideValue(value, cfgVar.DefaultValue?.GetType()); InvokeValueChanged(cfgVar, cfgVar.OverrideValueParsed); } } else { //or add another unregistered CVar //Note: the defaultValue is arbitrarily 0, it will get overwritten when the cvar is registered. var cVar = new ConfigVar(key, 0, CVar.NONE) {OverrideValue = value}; _configVars.Add(key, cVar); } } } private static object ParseOverrideValue(string value, Type? type) { if (type == typeof(int)) { return int.Parse(value); } if (type == typeof(bool)) { return bool.Parse(value); } if (type == typeof(float)) { return float.Parse(value, CultureInfo.InvariantCulture); } if (type?.IsEnum ?? false) { return Enum.Parse(type, value); } // Must be a string. return value; } /// /// Converts a TomlObject into its native type. /// /// The object to convert. /// The boxed native type of the TomlObject. private static object TypeConvert(TomlObject obj) { var tmlType = obj.TomlType; switch (tmlType) { case TomlObjectType.Bool: return obj.Get(); case TomlObjectType.Float: return obj.Get(); case TomlObjectType.Int: return obj.Get(); case TomlObjectType.String: return obj.Get(); default: throw new InvalidConfigurationException($"Cannot convert {tmlType}."); } } private static void InvokeValueChanged(ConfigVar var, object value) { var.ValueChangedInvoker?.Invoke(var.ValueChanged!, value); } /// /// Holds the data for a single configuration variable. /// protected sealed class ConfigVar { /// /// Constructs a CVar. /// /// The name of the CVar. This needs to contain only printable characters. /// Underscores '_' are reserved. Everything before the last underscore is a table identifier, /// everything after is the CVar name in the TOML document. /// The default value of this CVar. /// Optional flags to modify the behavior of this CVar. public ConfigVar(string name, object defaultValue, CVar flags) { Name = name; DefaultValue = defaultValue; Flags = flags; } /// /// The name of the CVar. /// public string Name { get; } /// /// The default value of this CVar. /// public object DefaultValue { get; set; } /// /// Optional flags to modify the behavior of this CVar. /// public CVar Flags { get; set; } /// /// The current value of this CVar. /// public object? Value { get; set; } /// /// Has this CVar been registered in code? /// public bool Registered { get; set; } /// /// Was the CVar present in the config file? /// If so we need to always re-save it even if it's not ARCHIVE. /// public bool ConfigModified; /// /// Invoked when the value of this CVar is changed. /// public Delegate? ValueChanged { get; set; } public Action? ValueChangedInvoker { get; set; } // We don't know what the type of the var is until it's registered. // So we can't actually parse them until then. // So we keep the raw string around. public string? OverrideValue { get; set; } public object? OverrideValueParsed { get; set; } } } [Serializable] [Virtual] public class InvalidConfigurationException : Exception { public InvalidConfigurationException() { } public InvalidConfigurationException(string message) : base(message) { } public InvalidConfigurationException(string message, Exception inner) : base(message, inner) { } protected InvalidConfigurationException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } } }