diff --git a/OpenRA.Game/ModData.cs b/OpenRA.Game/ModData.cs index 17ff2fec35..03d860c615 100644 --- a/OpenRA.Game/ModData.cs +++ b/OpenRA.Game/ModData.cs @@ -213,6 +213,11 @@ namespace OpenRA return modules.GetOrDefault(); } + public T GetSettings() where T : SettingsModule + { + return Game.Settings.GetOrCreate(ObjectCreator, Manifest.Id); + } + public void Dispose() { LoadScreen?.Dispose(); diff --git a/OpenRA.Game/Settings.cs b/OpenRA.Game/Settings.cs index e07e375775..8e32c79792 100644 --- a/OpenRA.Game/Settings.cs +++ b/OpenRA.Game/Settings.cs @@ -15,6 +15,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Reflection; using OpenRA.Primitives; namespace OpenRA @@ -46,7 +47,47 @@ namespace OpenRA public enum WorldViewport { Native, Close, Medium, Far } - public class ServerSettings + public abstract class SettingsModule + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class YamlNodeAttribute(string key, bool shared = true) : Attribute + { + public readonly string Key = key; + public readonly bool Shared = shared; + } + + [FieldLoader.Ignore] + internal string ModInstance; + + [FieldLoader.Ignore] + public readonly MiniYamlBuilder Yaml; + + internal void Commit() + { + var defaultValues = Activator.CreateInstance(GetType()); + var fields = FieldLoader.GetTypeLoadInfo(GetType()); + foreach (var fli in fields) + { + var serialized = FieldSaver.FormatValue(this, fli.Field); + var defaultSerialized = FieldSaver.FormatValue(defaultValues, fli.Field); + + // Fields with their default value are not saved in the settings yaml + // Make sure that we erase any previously defined custom values + Yaml.Nodes.RemoveAll(n => n.Key == fli.YamlName); + if (serialized != defaultSerialized) + Yaml.Nodes.Add(new MiniYamlNodeBuilder(fli.YamlName, new MiniYamlBuilder(serialized))); + } + } + + public void Save() + { + Commit(); + Game.Settings.Save(false); + } + } + + [YamlNode("Server", shared: true)] + public class ServerSettings : SettingsModule { [Desc("Sets the server name.")] public string Name = ""; @@ -138,7 +179,8 @@ namespace OpenRA } } - public class DebugSettings + [YamlNode("Debug", shared: true)] + public class DebugSettings : SettingsModule { [Desc("Display average FPS and tick/render times")] public bool PerfText = false; @@ -186,7 +228,8 @@ namespace OpenRA public bool SyncCheckBotModuleCode = false; } - public class GraphicSettings + [YamlNode("Graphics", shared: true)] + public class GraphicSettings : SettingsModule { [Desc("This can be set to Windowed, Fullscreen or PseudoFullscreen.")] public WindowMode Mode = WindowMode.PseudoFullscreen; @@ -230,7 +273,8 @@ namespace OpenRA public GLProfile GLProfile = GLProfile.Automatic; } - public class SoundSettings + [YamlNode("Sound", shared: true)] + public class SoundSettings : SettingsModule { public float SoundVolume = 0.5f; public float MusicVolume = 0.5f; @@ -246,7 +290,8 @@ namespace OpenRA public bool MuteBackgroundMusic = false; } - public class PlayerSettings + [YamlNode("Player", shared: true)] + public class PlayerSettings : SettingsModule { [Desc("Sets the player nickname.")] public string Name = "Commander"; @@ -255,7 +300,8 @@ namespace OpenRA public ImmutableArray CustomColors = []; } - public class SinglePlayerGameSettings + [YamlNode("SinglePlayerSettings", shared: true)] + public class SinglePlayerGameSettings : SettingsModule { [Desc("Sets the Auto-save frequency, in seconds")] public int AutoSaveInterval = 0; @@ -263,7 +309,8 @@ namespace OpenRA public int AutoSaveMaxFileCount = 10; } - public class GameSettings + [YamlNode("Game", shared: true)] + public class GameSettings : SettingsModule { public string Platform = "Default"; @@ -329,120 +376,138 @@ namespace OpenRA { readonly string settingsFile; - public readonly PlayerSettings Player = new(); - public readonly GameSettings Game = new(); - public readonly SoundSettings Sound = new(); - public readonly GraphicSettings Graphics = new(); - public readonly ServerSettings Server = new(); - public readonly DebugSettings Debug = new(); - public readonly SinglePlayerGameSettings SinglePlayerSettings = new(); - internal Dictionary Keys = []; - public readonly Dictionary Sections; + public readonly PlayerSettings Player; + public readonly GameSettings Game; + public readonly SoundSettings Sound; + public readonly GraphicSettings Graphics; + public readonly ServerSettings Server; + public readonly DebugSettings Debug; + public readonly SinglePlayerGameSettings SinglePlayerSettings; + internal readonly Dictionary Keys = []; - // A direct clone of the file loaded from disk. - // Any changed settings will be merged over this on save, - // allowing us to persist any unknown configuration keys - readonly List yamlCache = []; + readonly Arguments args; + readonly TypeDictionary modules = []; + readonly List yaml; public Settings(string file, Arguments args) { settingsFile = file; - Sections = new Dictionary() - { - { "Player", Player }, - { "Game", Game }, - { "Sound", Sound }, - { "Graphics", Graphics }, - { "Server", Server }, - { "Debug", Debug }, - { "SinglePlayerSettings", SinglePlayerSettings }, - }; + this.args = args; - // Override fieldloader to ignore invalid entries - var err1 = FieldLoader.UnknownFieldAction; - var err2 = FieldLoader.InvalidValueAction; - try - { - FieldLoader.UnknownFieldAction = (s, f) => Console.WriteLine($"Ignoring unknown field `{s}` on `{f.Name}`"); + if (File.Exists(settingsFile)) + yaml = MiniYaml.FromFile(settingsFile, false) + .Select(n => new MiniYamlNodeBuilder(n)) + .ToList(); + else + yaml = []; - if (File.Exists(settingsFile)) - { - yamlCache = MiniYaml.FromFile(settingsFile, false).ToList(); - foreach (var yamlSection in yamlCache) - { - if (yamlSection.Key != null && Sections.TryGetValue(yamlSection.Key, out var settingsSection)) - LoadSectionYaml(yamlSection.Value, settingsSection); - } + // Load the default sections + Player = GetOrCreate(null); + Game = GetOrCreate(null); + Sound = GetOrCreate(null); + Graphics = GetOrCreate(null); + Server = GetOrCreate(null); + Debug = GetOrCreate(null); + SinglePlayerSettings = GetOrCreate(null); - var keysNode = yamlCache.FirstOrDefault(n => n.Key == "Keys"); - if (keysNode != null) - foreach (var node in keysNode.Value.Nodes) - if (node.Key != null) - Keys[node.Key] = FieldLoader.GetValue(node.Key, node.Value.Value); - } - - // Override with commandline args - foreach (var kv in Sections) - foreach (var f in kv.Value.GetType().GetFields()) - if (args.Contains(kv.Key + "." + f.Name)) - FieldLoader.LoadFieldOrProperty(kv.Value, f.Name, args.GetValue(kv.Key + "." + f.Name, "")); - } - finally - { - FieldLoader.UnknownFieldAction = err1; - FieldLoader.InvalidValueAction = err2; - } + var keysNode = yaml.FirstOrDefault(n => n.Key == "Keys"); + if (keysNode != null) + foreach (var node in keysNode.Value.Nodes) + if (node.Key != null) + Keys[node.Key] = FieldLoader.GetValue(node.Key, node.Value.Value); } - public void Save() + public T GetOrCreate(ObjectCreator objectCreator, string mod = null) where T : SettingsModule { - var yamlCacheBuilder = yamlCache.ConvertAll(n => new MiniYamlNodeBuilder(n)); - foreach (var kv in Sections) + var attribute = typeof(T).GetCustomAttribute(); + if (attribute == null) + throw new InvalidDataException("Settings modules must define a YamlNode attribute"); + + var module = attribute.Shared ? modules.GetOrDefault() : + modules.WithInterface().SingleOrDefault(m => m.ModInstance == mod); + + // Lazily load/create the module on first use + if (module == null) { - var sectionYaml = yamlCacheBuilder.FirstOrDefault(x => x.Key == kv.Key); - if (sectionYaml == null) + if (objectCreator != null) + module = (T)objectCreator.CreateBasic(typeof(T)); + else + module = Activator.CreateInstance(); + + var nodeKey = attribute.Key; + if (!attribute.Shared) { - sectionYaml = new MiniYamlNodeBuilder(kv.Key, new MiniYamlBuilder("")); - yamlCacheBuilder.Add(sectionYaml); + module.ModInstance = mod; + nodeKey = $"{attribute.Key}@{mod}"; } - var defaultValues = Activator.CreateInstance(kv.Value.GetType()); - var fields = FieldLoader.GetTypeLoadInfo(kv.Value.GetType()); - foreach (var fli in fields) + var err1 = FieldLoader.UnknownFieldAction; + var err2 = FieldLoader.InvalidValueAction; + try { - var serialized = FieldSaver.FormatValue(kv.Value, fli.Field); - var defaultSerialized = FieldSaver.FormatValue(defaultValues, fli.Field); - - // Fields with their default value are not saved in the settings yaml - // Make sure that we erase any previously defined custom values - if (serialized == defaultSerialized) - sectionYaml.Value.Nodes.RemoveAll(n => n.Key == fli.YamlName); - else + FieldLoader.InvalidValueAction = (s, t, f) => { - // Update or add the custom value - var fieldYaml = sectionYaml.Value.NodeWithKeyOrDefault(fli.YamlName); - if (fieldYaml != null) - fieldYaml.Value.Value = serialized; - else - sectionYaml.Value.Nodes.Add(new MiniYamlNodeBuilder(fli.YamlName, new MiniYamlBuilder(serialized))); + var ret = t.GetField(f)?.GetValue(module); + Console.WriteLine($"FieldLoader: Cannot parse `{s}` into `{f}:{t.Name}`; substituting default `{ret}`"); + return ret; + }; + + var node = yaml.FirstOrDefault(n => n.Key == nodeKey); + if (node == null) + { + node = new MiniYamlNodeBuilder(nodeKey, ""); + yaml.Add(node); + } + + typeof(T).GetField(nameof(SettingsModule.Yaml))?.SetValue(module, node.Value); + FieldLoader.Load(module, node.Value.Build()); + + // Override with commandline args + foreach (var f in typeof(T).GetFields()) + { + var argName = attribute.Key + "." + f.Name; + if (args.Contains(argName)) + FieldLoader.LoadFieldOrProperty(module, f.Name, args.GetValue(argName, "")); } } + finally + { + FieldLoader.UnknownFieldAction = err1; + FieldLoader.InvalidValueAction = err2; + } + + modules.Add(module); } - var keysYaml = yamlCacheBuilder.FirstOrDefault(x => x.Key == "Keys"); - if (keysYaml == null) + return module; + } + + public void Save(bool commitModules = true) + { + if (commitModules) + foreach (var m in modules) + ((SettingsModule)m).Commit(); + + var keysNode = yaml.FirstOrDefault(n => n.Key == "Keys"); + if (keysNode == null) { - keysYaml = new MiniYamlNodeBuilder("Keys", new MiniYamlBuilder("")); - yamlCacheBuilder.Add(keysYaml); + keysNode = new MiniYamlNodeBuilder("Keys", ""); + yaml.Add(keysNode); } - keysYaml.Value.Nodes.Clear(); + keysNode.Value.Nodes.Clear(); foreach (var kv in Keys) - keysYaml.Value.Nodes.Add(new MiniYamlNodeBuilder(kv.Key, FieldSaver.FormatValue(kv.Value))); + keysNode.Value.Nodes.Add(new MiniYamlNodeBuilder(kv.Key, FieldSaver.FormatValue(kv.Value))); - yamlCacheBuilder.WriteToFile(settingsFile); - yamlCache.Clear(); - yamlCache.AddRange(yamlCacheBuilder.Select(n => n.Build())); + // Filter out modules with no fields and force a newline between each module + var container = new[] { null, new MiniYamlNodeBuilder("", "") }; + IEnumerable AddSpacer(MiniYamlNodeBuilder n) + { + container[0] = n; + return container; + } + + yaml.Where(n => n.Value.Nodes.Count > 0).SelectMany(AddSpacer).WriteToFile(settingsFile); } static string SanitizedName(string dirty) @@ -484,18 +549,5 @@ namespace OpenRA return clean; } - - static void LoadSectionYaml(MiniYaml yaml, object section) - { - var defaults = Activator.CreateInstance(section.GetType()); - FieldLoader.InvalidValueAction = (s, t, f) => - { - var ret = defaults.GetType().GetField(f).GetValue(defaults); - Console.WriteLine($"FieldLoader: Cannot parse `{s}` into `{f}:{t.Name}`; substituting default `{ret}`"); - return ret; - }; - - FieldLoader.Load(section, yaml); - } } } diff --git a/OpenRA.Game/World.cs b/OpenRA.Game/World.cs index 320c476288..fb5d443cd3 100644 --- a/OpenRA.Game/World.cs +++ b/OpenRA.Game/World.cs @@ -34,6 +34,7 @@ namespace OpenRA readonly List effects = []; readonly List unpartitionedEffects = []; readonly List syncedEffects = []; + readonly ModData modData; readonly GameSettings gameSettings; readonly Queue> frameEndActions = []; @@ -178,6 +179,7 @@ namespace OpenRA { Type = type; OrderManager = orderManager; + this.modData = modData; Map = map; if (string.IsNullOrEmpty(modData.Manifest.DefaultOrderGenerator)) @@ -229,7 +231,7 @@ namespace OpenRA gameInfo.MapData = preview.ToBase64String(); RulesContainTemporaryBlocker = Map.Rules.Actors.Any(a => a.Value.HasTraitInfo()); - gameSettings = Game.Settings.Game; + gameSettings = GetSettings(); } public void AddToMaps(Actor self, IOccupySpace ios) @@ -622,6 +624,11 @@ namespace OpenRA // In the event the replay goes out of sync, it becomes no longer usable. For polish we permanently pause the world. ReplayTimestep = 0; } + + public T GetSettings() where T : SettingsModule + { + return modData.GetSettings(); + } } public readonly struct TraitPair(Actor actor, T trait) : IEquatable> diff --git a/OpenRA.Mods.Common/UtilityCommands/CreateManPage.cs b/OpenRA.Mods.Common/UtilityCommands/CreateManPage.cs index 0ce1822068..b2eb92435d 100644 --- a/OpenRA.Mods.Common/UtilityCommands/CreateManPage.cs +++ b/OpenRA.Mods.Common/UtilityCommands/CreateManPage.cs @@ -11,6 +11,7 @@ using System; using System.Linq; +using System.Reflection; namespace OpenRA.Mods.Common.UtilityCommands { @@ -23,6 +24,29 @@ namespace OpenRA.Mods.Common.UtilityCommands return true; } + static void WriteFields(string key, object value) + { + var fields = Utility.GetFields(value.GetType()); + foreach (var field in fields) + { + if (!Utility.HasAttribute(field)) + continue; + + Console.WriteLine(".TP"); + Console.Write($".BR {key}.{field.Name}="); + + var fieldValue = field.GetValue(value)?.ToString(); + if (fieldValue != null && !fieldValue.StartsWith("System.", StringComparison.Ordinal)) + Console.WriteLine($"\\fI{fieldValue}\\fR"); + else + Console.WriteLine(); + + var lines = Utility.GetCustomAttributes(field, false).SelectMany(d => d.Lines); + foreach (var line in lines) + Console.WriteLine(line); + } + } + [Desc("Create a man page in troff format.")] void IUtilityCommand.Run(Utility utility, string[] args) { @@ -37,31 +61,19 @@ namespace OpenRA.Mods.Common.UtilityCommands Console.WriteLine("starts the game."); Console.WriteLine(".SH OPTIONS"); - var sections = Game.Settings.Sections; - sections.Add("Launch", new LaunchArguments(new Arguments([]))); - foreach (var section in sections.OrderBy(s => s.Key)) + var sections = utility.ModData.ObjectCreator.GetTypesImplementing(); + foreach (var type in sections.OrderBy(s => s.Name)) { - var fields = Utility.GetFields(section.Value.GetType()); - foreach (var field in fields) - { - if (!Utility.HasAttribute(field)) - continue; + var attribute = type.GetCustomAttribute(); + if (attribute == null) + continue; - Console.WriteLine(".TP"); - - Console.Write($".BR {section.Key}.{field.Name}="); - var value = field.GetValue(section.Value); - if (value != null && !value.ToString().StartsWith("System.", StringComparison.Ordinal)) - Console.WriteLine($"\\fI{value}\\fR"); - else - Console.WriteLine(); - - var lines = Utility.GetCustomAttributes(field, false).SelectMany(d => d.Lines); - foreach (var line in lines) - Console.WriteLine(line); - } + var defaults = (SettingsModule)utility.ModData.ObjectCreator.CreateBasic(type); + WriteFields(attribute.Key, defaults); } + WriteFields("Launch", new LaunchArguments(new Arguments())); + Console.WriteLine(".SH FILES"); Console.WriteLine("Settings are stored in the ~/.openra user folder."); Console.WriteLine(".SH BUGS"); diff --git a/OpenRA.Mods.Common/UtilityCommands/Documentation/ExtractSettingsDocsCommand.cs b/OpenRA.Mods.Common/UtilityCommands/Documentation/ExtractSettingsDocsCommand.cs index b9f11badfa..19ffd7d859 100644 --- a/OpenRA.Mods.Common/UtilityCommands/Documentation/ExtractSettingsDocsCommand.cs +++ b/OpenRA.Mods.Common/UtilityCommands/Documentation/ExtractSettingsDocsCommand.cs @@ -11,6 +11,7 @@ using System; using System.Linq; +using System.Reflection; namespace OpenRA.Mods.Common.UtilityCommands.Documentation { @@ -23,6 +24,45 @@ namespace OpenRA.Mods.Common.UtilityCommands.Documentation return true; } + static void WriteFields(string key, object value) + { + var fields = Utility.GetFields(value.GetType()); + var writeHeader = true; + + foreach (var field in fields) + { + if (!Utility.HasAttribute(field)) + continue; + + if (writeHeader) + { + Console.WriteLine($"## {key}"); + if (key == "Launch") + Console.WriteLine("These are runtime parameters which can't be defined in `settings.yaml`."); + writeHeader = false; + } + + Console.WriteLine($"### {field.Name}"); + var lines = Utility.GetCustomAttributes(field, false).SelectMany(d => d.Lines); + foreach (var line in lines) + { + Console.WriteLine(line); + Console.WriteLine(); + } + + var fieldValue = field.GetValue(value)?.ToString(); + if (fieldValue != null && !fieldValue.StartsWith("System.", StringComparison.Ordinal)) + { + Console.WriteLine($"**Default Value:** {value}"); + Console.WriteLine(); + Console.WriteLine("```miniyaml"); + Console.WriteLine($"{key}: "); + Console.WriteLine($"\t{field.Name}: {fieldValue}"); + Console.WriteLine("```"); + } + } + } + [Desc("[VERSION]", "Generate settings documentation in markdown format.")] void IUtilityCommand.Run(Utility utility, string[] args) { @@ -55,46 +95,18 @@ namespace OpenRA.Mods.Common.UtilityCommands.Documentation "including settings gets stored there to aid portable installations."); Console.WriteLine(); - var sections = new Settings(null, new Arguments()).Sections; - sections.Add("Launch", new LaunchArguments(new Arguments([]))); - foreach (var section in sections.OrderBy(s => s.Key)) + var sections = utility.ModData.ObjectCreator.GetTypesImplementing(); + foreach (var type in sections.OrderBy(s => s.Name)) { - var fields = Utility.GetFields(section.Value.GetType()); - if (fields.Any(field => Utility.GetCustomAttributes(field, false).Length > 0)) - { - Console.WriteLine($"## {section.Key}"); - if (section.Key == "Launch") - { - Console.WriteLine("These are runtime parameters which can't be defined in `settings.yaml`."); - Console.WriteLine(); - } - } + var attribute = type.GetCustomAttribute(); + if (attribute == null) + continue; - foreach (var field in fields) - { - if (!Utility.HasAttribute(field)) - continue; - - Console.WriteLine($"### {field.Name}"); - var lines = Utility.GetCustomAttributes(field, false).SelectMany(d => d.Lines); - foreach (var line in lines) - { - Console.WriteLine(line); - Console.WriteLine(); - } - - var value = field.GetValue(section.Value); - if (value != null && !value.ToString().StartsWith("System.", StringComparison.Ordinal)) - { - Console.WriteLine($"**Default Value:** {value}"); - Console.WriteLine(); - Console.WriteLine("```miniyaml"); - Console.WriteLine($"{section.Key}: "); - Console.WriteLine($"\t{field.Name}: {value}"); - Console.WriteLine("```"); - } - } + var defaults = (SettingsModule)utility.ModData.ObjectCreator.CreateBasic(type); + WriteFields(attribute.Key, defaults); } + + WriteFields("Launch", new LaunchArguments(new Arguments())); } } }