first commit

This commit is contained in:
2025-07-06 00:23:46 +02:00
commit 38f50c8819
1788 changed files with 112878 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
using System;
using System.ComponentModel;
namespace NitroxModel.Serialization;
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = false)]
public sealed class PropertyDescriptionAttribute : DescriptionAttribute
{
public PropertyDescriptionAttribute(string desc) : base(desc)
{
}
public PropertyDescriptionAttribute(string desc, Type type)
{
if (type.IsEnum)
{
desc += $" {string.Join(", ", type.GetEnumNames())}";
DescriptionValue = desc;
}
}
}

View File

@@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
namespace NitroxModel.Serialization;
public abstract class NitroxConfig<T> where T : NitroxConfig<T>, new()
{
private static readonly Dictionary<string, object> unserializedMembersWarnOnceCache = [];
private static readonly Dictionary<string, MemberInfo> typeCache = [];
private readonly char[] newlineChars = Environment.NewLine.ToCharArray();
private readonly object locker = new();
public abstract string FileName { get; }
public static T Load(string saveDir)
{
T config = new();
config.Deserialize(saveDir);
return config;
}
public void Deserialize(string saveDir)
{
if (!File.Exists(Path.Combine(saveDir, FileName)))
{
return;
}
lock (locker)
{
Type type = GetType();
Dictionary<string, MemberInfo> typeCachedDict = GetTypeCacheDictionary();
using StreamReader reader = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Open, FileAccess.Read, FileShare.Read), Encoding.UTF8);
HashSet<MemberInfo> unserializedMembers = new(typeCachedDict.Values);
char[] lineSeparator = { '=' };
int lineNum = 0;
string readLine;
while ((readLine = reader.ReadLine()) != null)
{
lineNum++;
if (readLine.Length < 1 || readLine[0] == '#')
{
continue;
}
if (readLine.Contains('='))
{
string[] keyValuePair = readLine.Split(lineSeparator, 2);
// Ignore case for property names in file.
if (!typeCachedDict.TryGetValue(keyValuePair[0].ToLowerInvariant(), out MemberInfo member))
{
Log.Warn($"Property or field {keyValuePair[0]} does not exist on type {type.FullName}!");
continue;
}
unserializedMembers.Remove(member); // This member was serialized in the file
if (!NitroxConfig<T>.SetMemberValue(this, member, keyValuePair[1]))
{
(Type type, object value) logData = member switch
{
FieldInfo field => (field.FieldType, field.GetValue(this)),
PropertyInfo prop => (prop.PropertyType, prop.GetValue(this)),
_ => (typeof(string), "")
};
Log.Warn($@"Property ""({logData.type.Name}) {member.Name}"" has an invalid value {NitroxConfig<T>.StringifyValue(keyValuePair[1])} on line {lineNum}. Using default value: {NitroxConfig<T>.StringifyValue(logData.value)}");
}
}
else
{
Log.Error($"Incorrect format detected on line {lineNum} in {Path.GetFullPath(Path.Combine(saveDir, FileName))}:{Environment.NewLine}{readLine}");
}
}
if (unserializedMembers.Count != 0)
{
string[] unserializedProps = unserializedMembers
.Select(m =>
{
object value = null;
if (m is FieldInfo field)
{
value = field.GetValue(this);
}
else if (m is PropertyInfo prop)
{
value = prop.GetValue(this);
}
if (unserializedMembersWarnOnceCache.TryGetValue(m.Name, out object cachedValue))
{
if (Equals(value, cachedValue))
{
return null;
}
}
unserializedMembersWarnOnceCache[m.Name] = value;
return $" - {m.Name}: {value}";
})
.Where(i => i != null)
.ToArray();
if (unserializedProps.Length > 0)
{
Log.Warn($"{FileName} is using default values for the missing properties:{Environment.NewLine}{string.Join(Environment.NewLine, unserializedProps)}");
}
}
}
}
public void Serialize(string saveDir)
{
lock (locker)
{
Type type = GetType();
Dictionary<string, MemberInfo> typeCachedDict = GetTypeCacheDictionary();
try
{
Directory.CreateDirectory(saveDir);
using StreamWriter stream = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Create, FileAccess.Write), Encoding.UTF8);
WritePropertyDescription(type, stream);
foreach (string name in typeCachedDict.Keys)
{
MemberInfo member = typeCachedDict[name];
FieldInfo field = member as FieldInfo;
if (field != null)
{
WritePropertyDescription(member, stream);
NitroxConfig<T>.WriteProperty(field, field.GetValue(this), stream);
}
PropertyInfo property = member as PropertyInfo;
if (property != null)
{
WritePropertyDescription(member, stream);
NitroxConfig<T>.WriteProperty(property, property.GetValue(this), stream);
}
}
}
catch (UnauthorizedAccessException)
{
Log.Error($"Config file {FileName} exists but is a hidden file and cannot be modified, config file will not be updated. Please make file accessible");
}
}
}
/// <summary>
/// Ensures updates are properly persisted to the backing config file without overwriting user edits.
/// </summary>
public UpdateDiposable Update(string saveDir)
{
return new UpdateDiposable(this, saveDir);
}
private static Dictionary<string, MemberInfo> GetTypeCacheDictionary()
{
Type type = typeof(T);
if (typeCache.Count == 0)
{
IEnumerable<MemberInfo> members = type.GetFields()
.Where(f => f.Attributes != FieldAttributes.NotSerialized)
.Concat(type.GetProperties()
.Where(p => p.CanWrite)
.Cast<MemberInfo>());
try
{
foreach (MemberInfo member in members)
{
typeCache.Add(member.Name.ToLowerInvariant(), member);
}
}
catch (ArgumentException e)
{
Log.Error(e, $"Type {type.FullName} has properties that require case-sensitivity to be unique which is unsuitable for .properties format.");
throw;
}
}
return typeCache;
}
private static string StringifyValue(object value) => value switch
{
string _ => $@"""{value}""",
null => @"""""",
_ => value.ToString()
};
private static bool SetMemberValue(NitroxConfig<T> instance, MemberInfo member, string valueFromFile)
{
object ConvertFromStringOrDefault(Type typeOfValue, out bool isDefault, object defaultValue = default)
{
try
{
object newValue = TypeDescriptor.GetConverter(typeOfValue).ConvertFrom(null!, CultureInfo.InvariantCulture, valueFromFile);
isDefault = false;
return newValue;
}
catch (Exception)
{
isDefault = true;
return defaultValue;
}
}
bool usedDefault;
switch (member)
{
case FieldInfo field:
field.SetValue(instance, ConvertFromStringOrDefault(field.FieldType, out usedDefault, field.GetValue(instance)));
return !usedDefault;
case PropertyInfo prop:
prop.SetValue(instance, ConvertFromStringOrDefault(prop.PropertyType, out usedDefault, prop.GetValue(instance)));
return !usedDefault;
default:
throw new Exception($"Serialized member must be field or property: {member}.");
}
}
private static void WriteProperty<TMember>(TMember member, object value, StreamWriter stream) where TMember : MemberInfo
{
stream.Write(member.Name);
stream.Write('=');
stream.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
}
private void WritePropertyDescription(MemberInfo member, StreamWriter stream)
{
PropertyDescriptionAttribute attribute = member.GetCustomAttribute<PropertyDescriptionAttribute>();
if (attribute != null)
{
foreach (string line in attribute.Description.Split(newlineChars))
{
stream.Write("# ");
stream.WriteLine(line);
}
}
}
public readonly struct UpdateDiposable : IDisposable
{
private string SaveDir { get; }
private NitroxConfig<T> Config { get; }
public UpdateDiposable(NitroxConfig<T> config, string saveDir)
{
config.Deserialize(saveDir);
SaveDir = saveDir;
Config = config;
}
public void Dispose() => Config.Serialize(SaveDir);
}
}

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using NitroxModel.Helper;
namespace NitroxModel.Serialization;
public class ServerList
{
private const string SERVERS_FILE_NAME = "servers";
public const int DEFAULT_PORT = 11000;
private static ServerList instance;
private readonly List<Entry> entries = new();
public static ServerList Instance => instance ??= Refresh();
private static ServerList Default
{
get
{
ServerList list = new();
list.Add(new Entry("Your server", "127.0.0.1", DEFAULT_PORT));
return list;
}
}
public static string DefaultFile => Path.Combine(NitroxUser.AppDataPath, SERVERS_FILE_NAME);
public IEnumerable<Entry> Entries => entries;
public static ServerList Refresh()
{
return instance = From();
}
public static ServerList From()
{
// Create file if it doesn't exist yet.
string file = DefaultFile;
if (!File.Exists(file))
{
instance = Default;
instance.Save();
return instance;
}
ServerList list = new();
foreach (string line in File.ReadAllLines(file))
{
Entry entry = Entry.FromLine(line);
if (entry == null)
{
continue;
}
list.entries.Add(entry);
}
return list;
}
public void Add(Entry entry)
{
entries.Add(entry);
}
public void Save()
{
string file = DefaultFile;
using StreamWriter writer = new(new FileStream(file, FileMode.Create, FileAccess.Write));
foreach (Entry entry in entries)
{
if (entry.Persist)
{
writer.WriteLine(entry.ToString());
}
}
}
public void RemoveAt(int index)
{
if (index < 0 || index >= entries.Count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
entries.RemoveAt(index);
}
public class Entry
{
public string Name { get; }
public string Address { get; }
public int Port { get; }
/// <summary>
/// If true, entry will be saved to storage.
/// </summary>
public bool Persist { get; }
public Entry(string name, string address, int port, bool persist = true)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("name in ServerList.Entry constructor can't be null or whitespace");
}
Validate.NotNull(address);
Name = name.Trim();
Address = address.Trim();
Port = port;
Persist = persist;
}
public Entry(string name, IPAddress address, int port, bool persist = true) : this(name, address.ToString(), port, persist)
{
}
public static Entry FromLine(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return null;
}
string[] parts = line.Split('|');
int port;
string address;
switch (parts.Length)
{
case 2:
// Split from address as format "hostname:port".
string[] addressSplit = parts[1].Split(':');
address = addressSplit[0];
if (!int.TryParse(addressSplit.ElementAtOrDefault(1), out port))
{
port = DEFAULT_PORT;
}
break;
case 3:
address = parts[1].Trim();
if (!int.TryParse(parts[2], out port))
{
port = DEFAULT_PORT;
}
break;
default:
throw new Exception($"Expected server entry to have 2 or 3 parts: {line}");
}
string name = parts[0].Trim();
return new Entry(name, address, port);
}
public override string ToString()
{
return $"{Name}|{Address}|{Port}";
}
}
}

View File

@@ -0,0 +1,130 @@
using NitroxModel.DataStructures.GameLogic;
using NitroxModel.Helper;
using NitroxModel.Server;
namespace NitroxModel.Serialization
{
[PropertyDescription("Server settings can be changed here")]
public class SubnauticaServerConfig : NitroxConfig<SubnauticaServerConfig>
{
private int maxConnectionsSetting = 100;
private int initialSyncTimeoutSetting = 300000;
[PropertyDescription("Set to true to Cache entities for the whole map on next run. \nWARNING! Will make server load take longer on the cache run but players will gain a performance boost when entering new areas.")]
public bool CreateFullEntityCache { get; set; } = false;
private int saveIntervalSetting = 120000;
private int maxBackupsSetting = 10;
private string postSaveCommandPath = string.Empty;
public override string FileName => "server.cfg";
[PropertyDescription("Leave blank for a random spawn position")]
public string Seed { get; set; }
public int ServerPort { get; set; } = ServerList.DEFAULT_PORT;
[PropertyDescription("Prevents players from losing items on death")]
public bool KeepInventoryOnDeath { get; set; } = false;
[PropertyDescription("Measured in milliseconds")]
public int SaveInterval
{
get => saveIntervalSetting;
set
{
Validate.IsTrue(value >= 1000, "SaveInterval must be greater than 1000");
saveIntervalSetting = value;
}
}
public int MaxBackups
{
get => maxBackupsSetting;
set
{
Validate.IsTrue(value >= 0, "MaxBackups must be greater than or equal to 0");
maxBackupsSetting = value;
}
}
[PropertyDescription("Command to run following a successful world save (e.g. .exe, .bat, or PowerShell script). ")]
public string PostSaveCommandPath
{
get => postSaveCommandPath;
set => postSaveCommandPath = value?.Trim('"').Trim();
}
public int MaxConnections
{
get => maxConnectionsSetting;
set
{
Validate.IsTrue(value > 0, "MaxConnections must be greater than 0");
maxConnectionsSetting = value;
}
}
public int InitialSyncTimeout
{
get => initialSyncTimeoutSetting;
set
{
Validate.IsTrue(value > 30000, "InitialSyncTimeout must be greater than 30 seconds");
initialSyncTimeoutSetting = value;
}
}
public bool DisableConsole { get; set; }
public bool DisableAutoSave { get; set; }
public bool DisableAutoBackup { get; set; }
public string ServerPassword { get; set; } = string.Empty;
public string AdminPassword { get; set; } = StringHelper.GenerateRandomString(12);
[PropertyDescription("Possible values:", typeof(NitroxGameMode))]
public NitroxGameMode GameMode { get; set; } = NitroxGameMode.SURVIVAL;
[PropertyDescription("Possible values:", typeof(ServerSerializerMode))]
public ServerSerializerMode SerializerMode { get; set; } = ServerSerializerMode.JSON;
[PropertyDescription("Possible values:", typeof(Perms))]
public Perms DefaultPlayerPerm { get; set; } = Perms.PLAYER;
[PropertyDescription("\nDefault player stats below here")]
public float DefaultOxygenValue { get; set; } = 45;
public float DefaultMaxOxygenValue { get; set; } = 45;
public float DefaultHealthValue { get; set; } = 80;
public float DefaultHungerValue { get; set; } = 50.5f;
public float DefaultThirstValue { get; set; } = 90.5f;
[PropertyDescription("Recommended to keep at 0.1f which is the default starting value. If set to 0 then new players are cured by default.")]
public float DefaultInfectionValue { get; set; } = 0.1f;
public PlayerStatsData DefaultPlayerStats => new(DefaultOxygenValue, DefaultMaxOxygenValue, DefaultHealthValue, DefaultHungerValue, DefaultThirstValue, DefaultInfectionValue);
[PropertyDescription("If set to true, the server will try to open port on your router via UPnP")]
public bool AutoPortForward { get; set; } = true;
[PropertyDescription("Determines whether the server will listen for and reply to LAN discovery requests.")]
public bool LANDiscoveryEnabled { get; set; } = true;
[PropertyDescription("When true, will reject any build actions detected as desynced")]
public bool SafeBuilding { get; set; } = true;
[PropertyDescription("When true and started in launcher, will use launcher UI as opposed to external window")]
public bool IsEmbedded { get; set; } = true;
[PropertyDescription("Activates/Deactivates Player versus Player damage/interactions")]
public bool PvPEnabled { get; set; } = true;
}
}