using System; using System.Collections.Generic; using System.Diagnostics; 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 partial class ConfigurationManager : IConfigurationManagerInternal { [Dependency] private IGameTiming _gameTiming = default!; [Dependency] private ILogManager _logManager = default!; private const char TABLE_DELIMITER = '.'; protected readonly Dictionary _configVars = new(); private ConfigFileStorage? _configFile; protected bool _isServer; protected readonly ReaderWriterLockSlim Lock = new(); private ISawmill _sawmill = default!; public event Action? OnCVarValueChanged; /// /// 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) { TomlTable tblRoot; try { tblRoot = Toml.ReadStream(file); } catch (Exception e) { _sawmill.Error("Unable to load configuration from table:\n{0}", e); return []; } return LoadFromTomlTable(tblRoot); } private HashSet LoadFromTomlTable(TomlTable table) { var loaded = new HashSet(); var callbackEvents = new ValueList(); try { // Ensure callbacks are raised OUTSIDE the write lock. using (Lock.WriteGuard()) { foreach (var (cvar, value) in ParseCVarValuesFromToml(table)) { loaded.Add(cvar); LoadParsedVar(cvar, value, ref callbackEvents); } } } finally { RunDeferredInvokeCallbacks(in callbackEvents); } return loaded; } private void RunDeferredInvokeCallbacks(in ValueList callbackEvents) { foreach (var callback in callbackEvents) { InvokeValueChanged(callback); } } private void LoadParsedVar( string cvar, object value, ref ValueList changedInvokes) { if (_configVars.TryGetValue(cvar, out var cfgVar)) { // overwrite the value with the saved one var oldValue = GetConfigVarValue(cfgVar); var convertedValue = value; if (cfgVar.Type != value.GetType()) { try { convertedValue = ConvertToCVarType(value, cfgVar.Type!); } catch { _sawmill.Error($"Parsed cvar does not match registered cvar type. Name: {cvar}. Code Type: {cfgVar.Type}. Parsed type: {value.GetType()}"); return; } } changedInvokes.Add(SetupInvokeValueChanged(cfgVar, convertedValue, oldValue)); cfgVar.Value = convertedValue; } else { //or add another unregistered CVar cfgVar = AddUnregisteredCVar(cvar, value); } cfgVar.ConfigModified = true; } private ConfigVar AddUnregisteredCVar(string name, object value) { //Note: the initial defaultValue is null, but it will get overwritten when the cvar is registered. var cfgVar = new ConfigVar(name, null!, CVar.NONE) { Value = value }; _configVars.Add(name, cfgVar); return cfgVar; } public HashSet LoadDefaultsFromTomlStream(Stream stream) { var tblRoot = Toml.ReadStream(stream); var loaded = new HashSet(); var callbackEvents = new ValueList(); // Ensure callbacks are raised OUTSIDE the write lock. using (Lock.WriteGuard()) { foreach (var (cVarName, value) in ParseCVarValuesFromToml(tblRoot)) { if (!_configVars.TryGetValue(cVarName, out var cVar) || !cVar.Registered) { _sawmill.Error($"Trying to set unregistered variable '{cVarName}'"); continue; } var convertedValue = value; if (cVar.Type != value.GetType()) { try { convertedValue = ConvertToCVarType(value, cVar.Type!); } catch { _sawmill.Error($"Override TOML parsed cvar does not match registered cvar type. Name: {cVarName}. Code Type: {cVar.Type}. Toml type: {value.GetType()}"); continue; } } if (cVar.OverrideValue == null && cVar.Value == null) { var oldValue = GetConfigVarValue(cVar); callbackEvents.Add(SetupInvokeValueChanged(cVar, convertedValue, oldValue)); } cVar.DefaultValue = convertedValue; } } foreach (var callback in callbackEvents) { InvokeValueChanged(callback); } return loaded; } /// public HashSet LoadFromFile(string configFile) { try { HashSet result; using (var file = File.OpenRead(configFile)) { result = LoadFromTomlStream(file); } SetSaveFile(configFile); ApplyRollback(); _sawmill.Info($"Configuration loaded from file"); return result; } catch (Exception e) { _sawmill.Error("Unable to load configuration file:\n{0}", e); return new HashSet(0); } } public void SetSaveFile(string configFile) { _configFile = new ConfigFileStorageDisk { Path = configFile }; } public void SetVirtualConfig() { _configFile = new ConfigFileStorageVirtual(); } 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 table = SaveToTomlTable(cvars); Toml.WriteStream(table, stream); } private TomlTable SaveToTomlTable(IEnumerable cvars, Func? overrideValue = null) { var tblRoot = Toml.Create(); using (Lock.ReadGuard()) { foreach (var name in cvars) { if (!_configVars.TryGetValue(name, out var cVar)) continue; object? value; if (overrideValue != null) { value = overrideValue(name); } else { 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... ¯\_(ツ)_/¯ // If you add a type here, add it to .Rollback.cs too!!! // I can't share the code because of how the serialization layers work :( 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; } } } return tblRoot; } /// 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; switch (_configFile) { case ConfigFileStorageDisk disk: { using var file = File.Create(disk.Path); memoryStream.CopyTo(file); break; } case ConfigFileStorageVirtual @virtual: { @virtual.Stream.SetLength(0); memoryStream.CopyTo(@virtual.Stream); break; } default: { throw new UnreachableException(); } } _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.AssertEqual(defaultValue.GetType(), type); 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 (cVar.Value != null && type != cVar.Value.GetType()) { try { cVar.Value = ConvertToCVarType(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.Register(); if (cVar.OverrideValue != null) { cVar.OverrideValueParsed = ParseOverrideValue(cVar.OverrideValue, type); } return; } var cvar = new ConfigVar(name, defaultValue, flags); cvar.Register(); _configVars.Add(name, cvar); } 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 { object value; using (Lock.WriteGuard()) { var reg = _configVars[name]; value = GetConfigVarValue(reg); reg.ValueChanged.AddInPlace( (object value, in CVarChangeInfo info) => onValueChanged((T)value, info), onValueChanged); } if (invokeImmediately) { onValueChanged(GetCVar(name), new CVarChangeInfo(name, _gameTiming.CurTick, value, value)); } } 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 type in assembly .GetTypes() .Where(p => Attribute.IsDefined(p, typeof(CVarDefsAttribute)))) { LoadCVarsFromType(type); } } public void LoadCVarsFromType(Type containingType) { foreach (var defField in containingType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | 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)) { var oldValue = GetConfigVarValue(cVar); invoke = SetupInvokeValueChanged(cVar, value, oldValue, intendedTick); // Setting an overriden var just turns off the override, basically. cVar.OverrideValue = null; cVar.OverrideValueParsed = null; cVar.Value = value; } } 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}'"); if (cVar.OverrideValue == null && cVar.Value == null) { var oldValue = GetConfigVarValue(cVar); invoke = SetupInvokeValueChanged(cVar, value, oldValue); } cVar.DefaultValue = value; } if (invoke != null) InvokeValueChanged(invoke.Value); } public void OverrideDefault(CVarDef def, T value) where T : notnull { OverrideDefault(def.Name, value); } public object GetCVar(string name) { using var _ = Lock.ReadGuard(); if (_configVars.TryGetValue(name, out var cVar) && cVar.Registered) return GetConfigVarValue(cVar); throw new InvalidConfigurationException($"Trying to get unregistered variable '{name}'"); } /// public T GetCVar(string name) { return (T)GetCVar(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.Type!; } 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) continue; var newValue = ParseOverrideValue(value, cfgVar.Type!); var oldValue = GetConfigVarValue(cfgVar); invokes.Add(SetupInvokeValueChanged(cfgVar, newValue, oldValue)); cfgVar.OverrideValueParsed = newValue; } else { //or add another unregistered CVar //Note: the initial defaultValue is null, but it will get overwritten when the cvar is registered. var cVar = new ConfigVar(key, null!, 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 void InvokeValueChanged(in ValueChangedInvoke invoke) { try { OnCVarValueChanged?.Invoke(invoke.Info); } catch (Exception e) { _sawmill.Error($"Error while running OnCVarValueChanged callback: {e}"); } foreach (var entry in invoke.Invoke.Entries) { try { entry.Value!.Invoke(invoke.Value, in invoke.Info); } catch (Exception e) { _sawmill.Error($"Error while running OnValueChanged callback: {e}"); } } } private ValueChangedInvoke SetupInvokeValueChanged(ConfigVar var, object newValue, object oldValue, GameTick? tick = null) { tick ??= _gameTiming.CurTick; var info = new CVarChangeInfo(var.Name, tick.Value, newValue, oldValue); return new ValueChangedInvoke(info, var.ValueChanged); } private IEnumerable<(string cvar, object value)> ParseCVarValuesFromToml(TomlTable tblRoot) { 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); } } /// /// Try to convert a compatible value to the actual registration type of a CVar. /// /// /// When CVars are parsed from TOML, their in-code type is not known. /// This function does the necessary conversions from e.g. int to long. /// /// /// The value to convert. /// This must be a simple type like strings or integers. /// /// /// The registration type of the CVar. /// /// private static object ConvertToCVarType(object value, Type cVar) { if (cVar.IsEnum) return Enum.Parse(cVar, value.ToString() ?? string.Empty); return Convert.ChangeType(value, cVar); } internal List GetSubs(string name) { using (Lock.ReadGuard()) { var list = new List(); if (!_configVars.TryGetValue(name, out var cVar)) throw new InvalidConfigurationException($"Trying to get unregistered variable '{name}'"); foreach (var entry in cVar.ValueChanged.Entries) { list.Add((Delegate) entry.Equality!); } return list; } } /// /// 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; Flags = flags; _defaultValue = defaultValue; } /// /// The type of the cvar's value. This may be null until the cvar is registered. /// public Type? Type { get; internal set; } /// /// The name of the CVar. /// public string Name { get; } /// /// The default value of this CVar. /// public object DefaultValue { get => _defaultValue; set { if (Registered) DebugTools.AssertEqual(value.GetType(), Type); _defaultValue = value; } } /// /// Optional flags to modify the behavior of this CVar. /// public CVar Flags { get; set; } /// /// The current value of this CVar. /// public object? Value { get => _value; set { if (value != null && Registered) DebugTools.AssertEqual(value.GetType(), Type); _value = value; } } /// /// Has this CVar been registered in code? /// public bool Registered { get; private set; } public void Register() { if (Registered) { DebugTools.AssertNotNull(DefaultValue); DebugTools.AssertEqual(DefaultValue.GetType(), Type); DebugTools.Assert(Value == null || Value.GetType() == Type); DebugTools.Assert(OverrideValueParsed == null || OverrideValueParsed.GetType() == Type); return; } if (_defaultValue == null) throw new NullReferenceException("Must specify default value before registering"); if (Value != null && DefaultValue.GetType() != Value.GetType()) throw new Exception($"The cvar value & default value must be of the same type"); if (OverrideValueParsed != null && DefaultValue.GetType() != OverrideValueParsed.GetType()) throw new Exception($"The cvar override value & default value must be of the same type"); Type = DefaultValue.GetType(); Registered = true; } /// /// 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; private object _defaultValue; private object? _value; private object? _overrideValueParsed; // 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 => _overrideValueParsed; set { if (value != null && Registered) DebugTools.AssertEqual(value.GetType(), Type); _overrideValueParsed = value; } } } /// /// All data we need to invoke a deferred ValueChanged handler outside of a write lock. /// private struct ValueChangedInvoke { public InvokeList Invoke; public object Value => Info.NewValue; public CVarChangeInfo Info; public ValueChangedInvoke(CVarChangeInfo info, InvokeList invoke) : this() { Info = info; Invoke = invoke; } } protected delegate void ValueChangedDelegate(object value, in CVarChangeInfo info); private abstract class ConfigFileStorage; private sealed class ConfigFileStorageDisk : ConfigFileStorage { public required string Path; public override string ToString() { return Path; } } private sealed class ConfigFileStorageVirtual : ConfigFileStorage { // I did not realize when adding this class that there is currently no way to *load* this data again. // Oh well, might be useful for a future unit test. public readonly MemoryStream Stream = new(); public override string ToString() { return ""; } } } [Serializable] [Virtual] public class InvalidConfigurationException : Exception { public InvalidConfigurationException() { } public InvalidConfigurationException(string message) : base(message) { } public InvalidConfigurationException(string message, Exception inner) : base(message, inner) { } } }