using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using Nett; using Robust.Shared.Collections; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Robust.Shared.Configuration { /// /// Stores and manages global configuration variables. /// [Virtual] internal class ConfigurationManager : IConfigurationManagerInternal { [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly ILogManager _logManager = default!; private const char TABLE_DELIMITER = '.'; protected readonly Dictionary _configVars = new(); private string? _configFile; protected bool _isServer; protected readonly ReaderWriterLockSlim Lock = new(); private ISawmill _sawmill = default!; /// /// Constructs a new ConfigurationManager. /// public ConfigurationManager() { } public void Initialize(bool isServer) { _isServer = isServer; _sawmill = _logManager.GetSawmill("cfg"); } public virtual void Shutdown() { using var _ = Lock.WriteGuard(); _configVars.Clear(); _configFile = null; } /// public HashSet LoadFromTomlStream(Stream file) { var loaded = new HashSet(); try { var callbackEvents = new ValueList(); // Ensure callbacks are raised OUTSIDE the write lock. using (Lock.WriteGuard()) { foreach (var (cvar, value) in ParseCVarValuesFromToml(file)) { loaded.Add(cvar); LoadTomlVar(cvar, value, ref callbackEvents); } } foreach (var callback in callbackEvents) { InvokeValueChanged(callback); } } catch (Exception e) { loaded.Clear(); _sawmill.Warning("Unable to load configuration from stream:\n{0}", e); } return loaded; } private void LoadTomlVar( string cvar, object value, ref ValueList changedInvokes) { if (_configVars.TryGetValue(cvar, out var cfgVar)) { // overwrite the value with the saved one cfgVar.Value = value; if (SetupInvokeValueChanged(cfgVar, value) is { } invoke) changedInvokes.Add(invoke); } else { //or add another unregistered CVar //Note: the defaultValue is arbitrarily 0, it will get overwritten when the cvar is registered. cfgVar = new ConfigVar(cvar, 0, CVar.NONE) { Value = value }; _configVars.Add(cvar, cfgVar); } cfgVar.ConfigModified = true; } public HashSet LoadDefaultsFromTomlStream(Stream stream) { var loaded = new HashSet(); foreach (var (cvar, value) in ParseCVarValuesFromToml(stream)) { loaded.Add(cvar); OverrideDefault(cvar, value); } return loaded; } /// public HashSet LoadFromFile(string configFile) { try { using var file = File.OpenRead(configFile); var result = LoadFromTomlStream(file); _configFile = configFile; _sawmill.Info($"Configuration loaded from file"); return result; } catch (Exception e) { _sawmill.Warning("Unable to load configuration file:\n{0}", e); return new HashSet(0); } } public void SetSaveFile(string configFile) { _configFile = configFile; } public void CheckUnusedCVars() { if (!GetCVar(CVars.CfgCheckUnused)) return; using (Lock.ReadGuard()) { foreach (var cVar in _configVars.Values) { if (cVar.Registered) continue; _sawmill.Warning("Unknown CVar found (typo in config?): {CVar}", cVar.Name); } } } /// public void SaveToTomlStream(Stream stream, IEnumerable cvars) { var tblRoot = Toml.Create(); using (Lock.ReadGuard()) { foreach (var name in cvars) { if (!_configVars.TryGetValue(name, out var cVar)) continue; var value = cVar.Value; if (value == null && cVar.Registered) { value = cVar.DefaultValue; } if (value == null) { _sawmill.Error($"CVar {name} has no value or default value, was the default value registered as null?"); 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: _sawmill.Warning($"Cannot serialize '{name}', unsupported type."); break; } } } Toml.WriteStream(tblRoot, stream); } /// public void SaveToFile() { if (_configFile == null) { _sawmill.Warning("Cannot save the config file, because one was never loaded."); return; } try { // Always write if it was present when reading from the config file, otherwise: // Don't write if Archive flag is not set. // Don't write if the cVar is the default value. var cvars = _configVars.Where(x => x.Value.ConfigModified || ((x.Value.Flags & CVar.ARCHIVE) != 0 && x.Value.Value != null && !x.Value.Value.Equals(x.Value.DefaultValue))).Select(x => x.Key); // Write in-memory to avoid bulldozing config file on exception. var memoryStream = new MemoryStream(); SaveToTomlStream(memoryStream, cvars); memoryStream.Position = 0; using var file = File.Create(_configFile); memoryStream.CopyTo(file); _sawmill.Info($"config saved to '{_configFile}'."); } catch (Exception e) { _sawmill.Warning($"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; } using var _ = Lock.WriteGuard(); if (_configVars.TryGetValue(name, out var cVar)) { if (cVar.Registered) _sawmill.Error($"The variable '{name}' has already been registered."); if (!type.IsEnum && cVar.Value != null && !type.IsAssignableFrom(cVar.Value.GetType())) { try { // try convert thing like int to float. cVar.Value = Convert.ChangeType(cVar.Value, type); } catch { _sawmill.Error($"TOML parsed cvar does not match registered cvar type. Name: {name}. Code Type: {type.Name}. Toml type: {cVar.Value.GetType().Name}"); return; } } 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 }); } 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 { using (Lock.WriteGuard()) { var reg = _configVars[name]; reg.ValueChanged.AddInPlace( (object value, in CVarChangeInfo _) => onValueChanged((T)value), onValueChanged); } 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 { using var _ = Lock.WriteGuard(); var reg = _configVars[name]; reg.ValueChanged.RemoveInPlace(onValueChanged); } public void OnValueChanged(CVarDef cVar, CVarChanged onValueChanged, bool invokeImmediately = false) where T : notnull { OnValueChanged(cVar.Name, onValueChanged, invokeImmediately); } public void OnValueChanged(string name, CVarChanged onValueChanged, bool invokeImmediately = false) where T : notnull { using (Lock.WriteGuard()) { var reg = _configVars[name]; reg.ValueChanged.AddInPlace( (object value, in CVarChangeInfo info) => onValueChanged((T)value, info), onValueChanged); } if (invokeImmediately) { onValueChanged(GetCVar(name), new CVarChangeInfo(_gameTiming.CurTick)); } } public void UnsubValueChanged(CVarDef cVar, CVarChanged onValueChanged) where T : notnull { UnsubValueChanged(cVar.Name, onValueChanged); } public void UnsubValueChanged(string name, CVarChanged onValueChanged) where T : notnull { using var _ = Lock.WriteGuard(); var reg = _configVars[name]; reg.ValueChanged.RemoveInPlace(onValueChanged); } 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) { using var _ = Lock.ReadGuard(); return _configVars.TryGetValue(name, out var cVar) && cVar.Registered; } public CVar GetCVarFlags(string name) { using var _ = Lock.ReadGuard(); return _configVars[name].Flags; } /// public IEnumerable GetRegisteredCVars() { using var _ = Lock.ReadGuard(); // Have to .ToArray() so the lock is held for the whole iteration operation. // This function is only currently used for the cvar ? command so I'm not too worried. return _configVars.Where(c => c.Value.Registered).Select(p => p.Key).ToArray(); } /// public virtual void SetCVar(string name, object value, bool force = false) { SetCVarInternal(name, value, _gameTiming.CurTick); } protected void SetCVarInternal(string name, object value, GameTick intendedTick) { ValueChangedInvoke? invoke = null; using (Lock.WriteGuard()) { //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; invoke = SetupInvokeValueChanged(cVar, value, intendedTick); } } else throw new InvalidConfigurationException($"Trying to set unregistered variable '{name}'"); } if (invoke != null) InvokeValueChanged(invoke.Value); } public void SetCVar(CVarDef def, T value, bool force = false) where T : notnull { SetCVar(def.Name, value, force); } public void OverrideDefault(string name, object value) { ValueChangedInvoke? invoke = null; using (Lock.WriteGuard()) { //TODO: Make flags work, required non-derpy net system. if (!_configVars.TryGetValue(name, out var cVar) || !cVar.Registered) throw new InvalidConfigurationException($"Trying to set unregistered variable '{name}'"); cVar.DefaultValue = value; if (cVar.OverrideValue == null && cVar.Value == null) invoke = SetupInvokeValueChanged(cVar, value); } if (invoke != null) InvokeValueChanged(invoke.Value); } public void OverrideDefault(CVarDef def, T value) where T : notnull { OverrideDefault(def.Name, value); } /// public T GetCVar(string name) { using var _ = Lock.ReadGuard(); 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) { using var _ = Lock.ReadGuard(); 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.DefaultValue.GetType(); } protected static object GetConfigVarValue(ConfigVar cVar) { return cVar.OverrideValueParsed ?? cVar.Value ?? cVar.DefaultValue; } public void OverrideConVars(IEnumerable<(string key, string value)> cVars) { var invokes = new ValueList(); using (Lock.WriteGuard()) { 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()); if (SetupInvokeValueChanged(cfgVar, cfgVar.OverrideValueParsed) is { } invoke) invokes.Add(invoke); } } 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); } } } foreach (var invoke in invokes) { InvokeValueChanged(invoke); } } 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); } if (type == typeof(long)) { return long.Parse(value); } if (type == typeof(ushort)) { return ushort.Parse(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: var val = obj.Get(); if (val is >= int.MinValue and <= int.MaxValue) return obj.Get(); return val; case TomlObjectType.String: return obj.Get(); default: throw new InvalidConfigurationException($"Cannot convert {tmlType}."); } } private static void InvokeValueChanged(in ValueChangedInvoke invoke) { foreach (var entry in invoke.Invoke.Entries) { entry.Value!.Invoke(invoke.Value, in invoke.Info); } } private ValueChangedInvoke? SetupInvokeValueChanged(ConfigVar var, object value, GameTick? tick = null) { if (var.ValueChanged.Count == 0) return null; return new ValueChangedInvoke { Info = new CVarChangeInfo(tick ?? _gameTiming.CurTick), Invoke = var.ValueChanged, Value = value }; } private IEnumerable<(string cvar, object value)> ParseCVarValuesFromToml(Stream stream) { var tblRoot = Toml.ReadStream(stream); return ProcessTomlObject(tblRoot, ""); IEnumerable<(string cvar, object value)> ProcessTomlObject(TomlObject obj, string tablePath) { if (obj is TomlTable table) { foreach (var kvTml in table) { string newPath; if ((kvTml.Value is TomlTable)) newPath = tablePath + kvTml.Key + TABLE_DELIMITER; else newPath = tablePath + kvTml.Key; foreach (var tuple in ProcessTomlObject(kvTml.Value, newPath)) { yield return tuple; } } yield break; } var tomlValue = TypeConvert(obj); yield return (tablePath, tomlValue); } } /// /// 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; public InvokeList ValueChanged; // 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; } } /// /// All data we need to invoke a deferred ValueChanged handler outside of a write lock. /// private struct ValueChangedInvoke { public InvokeList Invoke; public object Value; public CVarChangeInfo Info; } protected delegate void ValueChangedDelegate(object value, in CVarChangeInfo info); } [Serializable] [Virtual] public class InvalidConfigurationException : Exception { public InvalidConfigurationException() { } public InvalidConfigurationException(string message) : base(message) { } public InvalidConfigurationException(string message, Exception inner) : base(message, inner) { } } }