Implement modular settings backend.

This commit is contained in:
Paul Chote
2025-12-22 09:12:55 +00:00
committed by Gustas Kažukauskas
parent 962e7e911d
commit 575f6624a2
5 changed files with 255 additions and 167 deletions

View File

@@ -213,6 +213,11 @@ namespace OpenRA
return modules.GetOrDefault<T>();
}
public T GetSettings<T>() where T : SettingsModule
{
return Game.Settings.GetOrCreate<T>(ObjectCreator, Manifest.Id);
}
public void Dispose()
{
LoadScreen?.Dispose();

View File

@@ -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<Color> 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<string, Hotkey> Keys = [];
public readonly Dictionary<string, object> 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<string, Hotkey> 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<MiniYamlNode> yamlCache = [];
readonly Arguments args;
readonly TypeDictionary modules = [];
readonly List<MiniYamlNodeBuilder> yaml;
public Settings(string file, Arguments args)
{
settingsFile = file;
Sections = new Dictionary<string, object>()
{
{ "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<PlayerSettings>(null);
Game = GetOrCreate<GameSettings>(null);
Sound = GetOrCreate<SoundSettings>(null);
Graphics = GetOrCreate<GraphicSettings>(null);
Server = GetOrCreate<ServerSettings>(null);
Debug = GetOrCreate<DebugSettings>(null);
SinglePlayerSettings = GetOrCreate<SinglePlayerGameSettings>(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<Hotkey>(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<Hotkey>(node.Key, node.Value.Value);
}
public void Save()
public T GetOrCreate<T>(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<SettingsModule.YamlNodeAttribute>();
if (attribute == null)
throw new InvalidDataException("Settings modules must define a YamlNode attribute");
var module = attribute.Shared ? modules.GetOrDefault<T>() :
modules.WithInterface<T>().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<T>();
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<MiniYamlNodeBuilder> 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);
}
}
}

View File

@@ -34,6 +34,7 @@ namespace OpenRA
readonly List<IEffect> effects = [];
readonly List<IEffect> unpartitionedEffects = [];
readonly List<ISync> syncedEffects = [];
readonly ModData modData;
readonly GameSettings gameSettings;
readonly Queue<Action<World>> 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<ITemporaryBlockerInfo>());
gameSettings = Game.Settings.Game;
gameSettings = GetSettings<GameSettings>();
}
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<T>() where T : SettingsModule
{
return modData.GetSettings<T>();
}
}
public readonly struct TraitPair<T>(Actor actor, T trait) : IEquatable<TraitPair<T>>

View File

@@ -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<DescAttribute>(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<DescAttribute>(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<SettingsModule>();
foreach (var type in sections.OrderBy(s => s.Name))
{
var fields = Utility.GetFields(section.Value.GetType());
foreach (var field in fields)
{
if (!Utility.HasAttribute<DescAttribute>(field))
continue;
var attribute = type.GetCustomAttribute<SettingsModule.YamlNodeAttribute>();
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<DescAttribute>(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");

View File

@@ -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<DescAttribute>(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<DescAttribute>(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<SettingsModule>();
foreach (var type in sections.OrderBy(s => s.Name))
{
var fields = Utility.GetFields(section.Value.GetType());
if (fields.Any(field => Utility.GetCustomAttributes<DescAttribute>(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<SettingsModule.YamlNodeAttribute>();
if (attribute == null)
continue;
foreach (var field in fields)
{
if (!Utility.HasAttribute<DescAttribute>(field))
continue;
Console.WriteLine($"### {field.Name}");
var lines = Utility.GetCustomAttributes<DescAttribute>(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()));
}
}
}