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,427 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using NitroxModel.DataStructures;
using NitroxModel.DataStructures.GameLogic;
using NitroxModel.DataStructures.Unity;
using NitroxServer.GameLogic.Entities.Spawning;
using NitroxServer.UnityStubs;
using ProtoBufNet;
namespace NitroxServer.Serialization
{
/**
* Parses the files in build18 in the format of batch-cells-x-y-z-slot-type.bin
* These files contain serialized GameObjects with EntitySlot components. These
* represent areas that entities (creatures, objects) can spawn within the world.
* This class consolidates the gameObject, entitySlot, and cellHeader data to
* create EntitySpawnPoint objects.
*/
public class BatchCellsParser
{
private readonly EntitySpawnPointFactory entitySpawnPointFactory;
private readonly ServerProtoBufSerializer serializer;
private readonly Dictionary<string, Type> surrogateTypes;
public BatchCellsParser(EntitySpawnPointFactory entitySpawnPointFactory, ServerProtoBufSerializer serializer)
{
this.entitySpawnPointFactory = entitySpawnPointFactory;
this.serializer = serializer;
surrogateTypes = new Dictionary<string, Type>
{
{ "UnityEngine.Transform", typeof(NitroxTransform) },
{ "UnityEngine.Vector3", typeof(NitroxVector3) },
{ "UnityEngine.Quaternion", typeof(NitroxQuaternion) }
};
}
public List<EntitySpawnPoint> ParseBatchData(NitroxInt3 batchId)
{
List<EntitySpawnPoint> spawnPoints = new List<EntitySpawnPoint>();
ParseFile(batchId, "CellsCache", "baked-", "", spawnPoints);
return spawnPoints;
}
public void ParseFile(NitroxInt3 batchId, string pathPrefix, string prefix, string suffix, List<EntitySpawnPoint> spawnPoints)
{
string subnauticaPath = NitroxUser.GamePath;
if (string.IsNullOrEmpty(subnauticaPath))
{
return;
}
string path = Path.Combine(subnauticaPath, GameInfo.Subnautica.DataFolder, "StreamingAssets", "SNUnmanagedData", "Build18");
string fileName = Path.Combine(path, pathPrefix, $"{prefix}batch-cells-{batchId.X}-{batchId.Y}-{batchId.Z}{suffix}.bin");
if (!File.Exists(fileName))
{
return;
}
ParseCacheCells(batchId, fileName, spawnPoints);
}
/**
* It is suspected that 'cache' is a misnomer carried over from when UWE was actually doing procedurally
* generated worlds. In the final release, this 'cache' has simply been baked into a final version that
* we can parse.
*/
private void ParseCacheCells(NitroxInt3 batchId, string fileName, List<EntitySpawnPoint> spawnPoints)
{
using Stream stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
CellsFileHeader cellsFileHeader = serializer.Deserialize<CellsFileHeader>(stream);
for (int cellCounter = 0; cellCounter < cellsFileHeader.NumCells; cellCounter++)
{
CellHeaderEx cellHeader = serializer.Deserialize<CellHeaderEx>(stream);
byte[] serialData = new byte[cellHeader.DataLength];
stream.ReadStreamExactly(serialData, serialData.Length);
ParseGameObjectsWithHeader(serialData, batchId, cellHeader.CellId, cellHeader.Level, spawnPoints, out bool wasLegacy);
if (!wasLegacy)
{
byte[] legacyData = new byte[cellHeader.LegacyDataLength];
stream.ReadStreamExactly(legacyData, legacyData.Length);
ParseGameObjectsWithHeader(legacyData, batchId, cellHeader.CellId, cellHeader.Level, spawnPoints, out _);
byte[] waiterData = new byte[cellHeader.WaiterDataLength];
stream.ReadStreamExactly(waiterData, waiterData.Length);
ParseGameObjectsFromStream(new MemoryStream(waiterData), batchId, cellHeader.CellId, cellHeader.Level, spawnPoints);
}
}
}
private void ParseGameObjectsWithHeader(byte[] data, NitroxInt3 batchId, NitroxInt3 cellId, int level, List<EntitySpawnPoint> spawnPoints, out bool wasLegacy)
{
wasLegacy = false;
if (data.Length == 0)
{
return;
}
using Stream stream = new MemoryStream(data);
StreamHeader header = serializer.Deserialize<StreamHeader>(stream);
if (ReferenceEquals(header, null))
{
return;
}
ParseGameObjectsFromStream(stream, batchId, cellId, level, spawnPoints);
wasLegacy = header.Version < 9;
}
private void ParseGameObjectsFromStream(Stream stream, NitroxInt3 batchId, NitroxInt3 cellId, int level, List<EntitySpawnPoint> spawnPoints)
{
LoopHeader gameObjectCount = serializer.Deserialize<LoopHeader>(stream);
for (int goCounter = 0; goCounter < gameObjectCount.Count; goCounter++)
{
GameObject gameObject = DeserializeGameObject(stream);
DeserializeComponents(stream, gameObject);
// If it is an "Empty" GameObject, we need it to have serialized components
if (!gameObject.CreateEmptyObject || gameObject.SerializedComponents.Count > 0)
{
AbsoluteEntityCell absoluteEntityCell = new AbsoluteEntityCell(batchId, cellId, level);
NitroxTransform transform = gameObject.GetComponent<NitroxTransform>();
spawnPoints.AddRange(entitySpawnPointFactory.From(absoluteEntityCell, transform, gameObject));
}
}
}
private GameObject DeserializeGameObject(Stream stream)
{
return new(serializer.Deserialize<GameObjectData>(stream));
}
private void DeserializeComponents(Stream stream, GameObject gameObject)
{
gameObject.SerializedComponents.Clear();
LoopHeader components = serializer.Deserialize<LoopHeader>(stream);
for (int componentCounter = 0; componentCounter < components.Count; componentCounter++)
{
ComponentHeader componentHeader = serializer.Deserialize<ComponentHeader>(stream);
if (!surrogateTypes.TryGetValue(componentHeader.TypeName, out Type type))
{
type = AppDomain.CurrentDomain.GetAssemblies()
.Select(a => a.GetType(componentHeader.TypeName))
.FirstOrDefault(t => t != null);
}
Validate.NotNull(type, $"No type or surrogate found for {componentHeader.TypeName}!");
#if NET5_0_OR_GREATER
object component = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type);
#else
object component = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type);
#endif
long startPosition = stream.Position;
serializer.Deserialize(stream, component, type);
gameObject.AddComponent(component, type);
// SerializedComponents only matter if this is an "Empty" GameObject
if (gameObject.CreateEmptyObject && !type.Name.Equals(nameof(NitroxTransform)) && !type.Name.Equals("LargeWorldEntity"))
{
byte[] data = new byte[(int)(stream.Position - startPosition)];
stream.Position = startPosition;
stream.ReadStreamExactly(data, data.Length);
SerializedComponent serializedComponent = new(componentHeader.TypeName, componentHeader.IsEnabled, data);
gameObject.SerializedComponents.Add(serializedComponent);
}
}
}
}
[ProtoContract]
public class CellsFileHeader
{
public override string ToString()
{
return string.Format("(version={0}, numCells={1})", Version, NumCells);
}
[ProtoMember(1)]
public int Version;
[ProtoMember(2)]
public int NumCells;
}
[ProtoContract]
public class CellHeader
{
public override string ToString()
{
return $"(cellId={CellId}, level={Level})";
}
[ProtoMember(1)]
public NitroxInt3 CellId;
[ProtoMember(2)]
public int Level;
}
[ProtoContract]
public class CellHeaderEx
{
public override string ToString()
{
return string.Format("(cellId={0}, level={1}, dataLength={2}, legacyDataLength={3}, waiterDataLength={4})", new object[]
{
CellId,
Level,
DataLength,
LegacyDataLength,
WaiterDataLength
});
}
[ProtoMember(1)]
public NitroxInt3 CellId;
[ProtoMember(2)]
public int Level;
[ProtoMember(3)]
public int DataLength;
[ProtoMember(4)]
public int LegacyDataLength;
[ProtoMember(5)]
public int WaiterDataLength;
// There's no point in spawning allowSpawnRestrictions as SpawnRestrictionEnforcer doesn't load any restrictions
}
[ProtoContract]
public class StreamHeader
{
[ProtoMember(1)]
public int Signature
{
get;
set;
}
[ProtoMember(2)]
public int Version
{
get;
set;
}
public void Reset()
{
Signature = 0;
Version = 0;
}
public override string ToString()
{
return string.Format("(UniqueIdentifier={0}, Version={1})", Signature, Version);
}
}
[ProtoContract]
public class LoopHeader
{
[ProtoMember(1)]
public int Count
{
get;
set;
}
public void Reset()
{
Count = 0;
}
public override string ToString()
{
return string.Format("(Count={0})", Count);
}
}
[ProtoContract]
public class GameObjectData
{
[ProtoMember(1)]
public bool CreateEmptyObject
{
get;
set;
}
[ProtoMember(2)]
public bool IsActive
{
get;
set;
}
[ProtoMember(3)]
public int Layer
{
get;
set;
}
[ProtoMember(4)]
public string Tag
{
get;
set;
}
[ProtoMember(6)]
public string Id
{
get;
set;
}
[ProtoMember(7)]
public string ClassId
{
get;
set;
}
[ProtoMember(8)]
public string Parent
{
get;
set;
}
[ProtoMember(9)]
public bool OverridePrefab
{
get;
set;
}
[ProtoMember(10)]
public bool MergeObject
{
get;
set;
}
public void Reset()
{
CreateEmptyObject = false;
IsActive = false;
Layer = 0;
Tag = null;
Id = null;
ClassId = null;
Parent = null;
OverridePrefab = false;
MergeObject = false;
}
public override string ToString()
{
return string.Format("(CreateEmptyObject={0}, IsActive={1}, Layer={2}, Tag={3}, Id={4}, ClassId={5}, Parent={6}, OverridePrefab={7}, MergeObject={8})", new object[]
{
CreateEmptyObject,
IsActive,
Layer,
Tag,
Id,
ClassId,
Parent,
OverridePrefab,
MergeObject
});
}
}
[ProtoContract]
public class ComponentHeader
{
[ProtoMember(1)]
public string TypeName
{
get;
set;
}
[ProtoMember(2)]
public bool IsEnabled
{
get;
set;
}
public void Reset()
{
TypeName = null;
IsEnabled = false;
}
public override string ToString()
{
return string.Format("(TypeName={0}, IsEnabled={1})", TypeName, IsEnabled);
}
}
}

View File

@@ -0,0 +1,14 @@
using System.IO;
namespace NitroxServer.Serialization;
public interface IServerSerializer
{
string FileEnding { get; }
void Serialize(Stream stream, object o);
void Serialize(string filePath, object o);
T Deserialize<T>(Stream stream);
T Deserialize<T>(string filePath);
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Serialization;
namespace NitroxServer.Serialization.Json;
public class AttributeContractResolver : DefaultContractResolver
{
//IDictionary to JsonArray
protected override JsonContract CreateContract(Type objectType)
{
if (objectType.GetInterfaces().Any(i => i == typeof(IDictionary) || i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)))
{
return base.CreateArrayContract(objectType);
}
return base.CreateContract(objectType);
}
}

View File

@@ -0,0 +1,19 @@
using System;
using Newtonsoft.Json;
using NitroxModel.DataStructures;
namespace NitroxServer.Serialization.Json
{
public class NitroxIdConverter : JsonConverter<NitroxId>
{
public override void WriteJson(JsonWriter writer, NitroxId value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override NitroxId ReadJson(JsonReader reader, Type objectType, NitroxId existingValue, bool hasExistingValue, JsonSerializer serializer)
{
return reader.Value == null ? null : new NitroxId((string)reader.Value);
}
}
}

View File

@@ -0,0 +1,18 @@
using System;
using Newtonsoft.Json;
using NitroxModel.DataStructures.GameLogic;
namespace NitroxServer.Serialization.Json
{
public class TechTypeConverter : JsonConverter<NitroxTechType>
{
public override void WriteJson(JsonWriter writer, NitroxTechType value, JsonSerializer serializer)
{
writer.WriteValue(value.Name);
}
public override NitroxTechType ReadJson(JsonReader reader, Type objectType, NitroxTechType existingValue, bool hasExistingValue, JsonSerializer serializer)
{
return reader.Value == null ? null : new NitroxTechType((string)reader.Value);
}
}
}

View File

@@ -0,0 +1,48 @@
#nullable enable
using System;
using Newtonsoft.Json.Linq;
namespace NitroxServer.Serialization.SaveDataUpgrades
{
public static class NewtonsoftExtensions
{
public static void Rename(this JToken token, string newName)
{
if (token == null)
{
throw new ArgumentNullException("token", "Cannot rename a null token");
}
JProperty property;
if (token.Type == JTokenType.Property)
{
if (token.Parent == null)
{
throw new InvalidOperationException("Cannot rename a property with no parent");
}
property = (JProperty)token;
}
else
{
if (token.Parent == null || token.Parent.Type != JTokenType.Property)
{
throw new InvalidOperationException("This token's parent is not a JProperty; cannot rename");
}
property = (JProperty)token.Parent;
}
// Note: to avoid triggering a clone of the existing property's value,
// we need to save a reference to it and then null out property.Value
// before adding the value to the new JProperty.
// Thanks to @dbc for the suggestion.
JToken? existingValue = property.Value;
property.Value = null!;
JProperty? newProperty = new JProperty(newName, existingValue);
property.Replace(newProperty);
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using NitroxServer.Serialization.Json;
namespace NitroxServer.Serialization.Upgrade
{
public abstract class SaveDataUpgrade
{
private readonly JsonConverter[] converters = { new NitroxIdConverter(), new TechTypeConverter(), new VersionConverter(), new KeyValuePairConverter(), new StringEnumConverter() };
public abstract Version TargetVersion { get; }
public static readonly Version MinimumSaveVersion = new(1, 8, 0, 0);
public void UpgradeSaveFiles(string saveDir, string fileEnding)
{
Log.Info($"┌── Executing {GetType().Name}");
string baseDataPath = Path.Combine(saveDir, $"BaseData{fileEnding}");
string playerDataPath = Path.Combine(saveDir, $"PlayerData{fileEnding}");
string worldDataPath = Path.Combine(saveDir, $"WorldData{fileEnding}");
string entityDataPath = Path.Combine(saveDir, $"EntityData{fileEnding}");
Log.Info("├── Parsing raw json");
JObject baseData = JObject.Parse(File.ReadAllText(baseDataPath));
JObject playerData = JObject.Parse(File.ReadAllText(playerDataPath));
JObject worldData = JObject.Parse(File.ReadAllText(worldDataPath));
JObject entityData = JObject.Parse(File.ReadAllText(entityDataPath));
Log.Info("├── Applying upgrade scripts");
UpgradeBaseData(baseData);
UpgradePlayerData(playerData);
UpgradeWorldData(worldData);
UpgradeEntityData(entityData);
Log.Info("└── Saving to disk");
File.WriteAllText(baseDataPath, baseData.ToString(Formatting.None, converters));
File.WriteAllText(playerDataPath, playerData.ToString(Formatting.None, converters));
File.WriteAllText(worldDataPath, worldData.ToString(Formatting.None, converters));
File.WriteAllText(entityDataPath, entityData.ToString(Formatting.None, converters));
}
protected virtual void UpgradeBaseData(JObject data) { }
protected virtual void UpgradePlayerData(JObject data) { }
protected virtual void UpgradeWorldData(JObject data) { }
protected virtual void UpgradeEntityData(JObject data) { }
}
}

View File

@@ -0,0 +1,22 @@
using System;
using Newtonsoft.Json.Linq;
using NitroxServer.Serialization.Upgrade;
namespace NitroxServer.Serialization.SaveDataUpgrades
{
public sealed class Upgrade_V1500 : SaveDataUpgrade
{
public override Version TargetVersion { get; } = new Version(1, 5, 0, 0);
protected override void UpgradeWorldData(JObject data)
{
data["GameData"]["StoryTiming"] = data["StoryTimingData"];
data.Property("StoryTimingData")?.Remove();
data["Seed"] = "TCCBIBZXAB"; //Default seed so life pod should stay the same
data["InventoryData"]["Modules"] = new JArray();
Log.Warn("Plants will still be counted as normal items with no growth progression. Re adding them to a container should fix this.");
Log.Warn("The precursor incubator may be unpowered and hatching progress will be reset");
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using NitroxServer.Serialization.Upgrade;
namespace NitroxServer.Serialization.SaveDataUpgrades
{
public class Upgrade_V1600 : SaveDataUpgrade
{
public override Version TargetVersion { get; } = new Version(1, 6, 0, 0);
protected override void UpgradeWorldData(JObject data)
{
List<string> cleanUnlockedTechTypes = data["GameData"]["PDAState"]["UnlockedTechTypes"].ToObject<List<string>>().Distinct().ToList();
List<string> cleanKnownTechTypes = data["GameData"]["PDAState"]["KnownTechTypes"].ToObject<List<string>>().Distinct().ToList();
List<string> cleanEncyclopediaEntries = data["GameData"]["PDAState"]["EncyclopediaEntries"].ToObject<List<string>>().Distinct().ToList();
data["GameData"]["PDAState"]["UnlockedTechTypes"] = new JArray(cleanUnlockedTechTypes);
data["GameData"]["PDAState"]["KnownTechTypes"] = new JArray(cleanKnownTechTypes);
data["GameData"]["PDAState"]["EncyclopediaEntries"] = new JArray(cleanEncyclopediaEntries);
List<JToken> cleanPdaLog = new List<JToken>();
List<JToken> pdaLog = data["GameData"]["PDAState"]["PdaLog"].ToObject<List<JToken>>();
foreach (JToken pdaLogEntry in pdaLog)
{
string Key = pdaLogEntry["Key"].ToString();
if (cleanPdaLog.All(entry => entry["Key"].ToString() != Key))
{
cleanPdaLog.Add(pdaLogEntry);
}
}
data["GameData"]["PDAState"]["PdaLog"] = new JArray(cleanPdaLog);
data.Property("ServerStartTime")?.Remove();
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NitroxModel.DataStructures;
using NitroxServer.Serialization.Upgrade;
namespace NitroxServer.Serialization.SaveDataUpgrades
{
public class Upgrade_V1601 : SaveDataUpgrade
{
public override Version TargetVersion { get; } = new Version(1, 6, 0, 1);
protected override void UpgradeWorldData(JObject data)
{
List<string> modules = new();
foreach (JToken moduleEntry in data["InventoryData"]["Modules"])
{
JToken itemId = moduleEntry["ItemId"];
if (modules.Contains(itemId.ToString()))
{
itemId = new NitroxId().ToString();
// this line is enough to modify the original data
moduleEntry["ItemId"] = itemId;
}
modules.Add(itemId.ToString());
}
}
}
}

View File

@@ -0,0 +1,64 @@
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using NitroxModel.Platforms.OS.Shared;
using NitroxServer.Serialization.Json;
namespace NitroxServer.Serialization;
public class ServerJsonSerializer : IServerSerializer
{
public const string FILE_ENDING = ".json";
private readonly JsonSerializer serializer;
public ServerJsonSerializer()
{
serializer = new JsonSerializer();
serializer.Error += delegate (object _, Newtonsoft.Json.Serialization.ErrorEventArgs e)
{
Log.Error(e.ErrorContext.Error, "Json serialization error: ");
};
serializer.TypeNameHandling = TypeNameHandling.Auto;
serializer.ContractResolver = new AttributeContractResolver();
serializer.Converters.Add(new NitroxIdConverter());
serializer.Converters.Add(new TechTypeConverter());
serializer.Converters.Add(new VersionConverter());
serializer.Converters.Add(new KeyValuePairConverter());
serializer.Converters.Add(new StringEnumConverter());
}
public string FileEnding => FILE_ENDING;
public void Serialize(Stream stream, object o)
{
stream.Position = 0;
using JsonTextWriter writer = new(new StreamWriter(stream));
serializer.Serialize(writer, o);
}
public void Serialize(string filePath, object o)
{
string tmpPath = Path.ChangeExtension(filePath, ".tmp");
using (StreamWriter stream = File.CreateText(tmpPath))
{
serializer.Serialize(stream, o);
}
FileSystem.Instance.ReplaceFile(tmpPath, filePath);
}
public T Deserialize<T>(Stream stream)
{
stream.Position = 0;
using JsonTextReader reader = new(new StreamReader(stream));
return serializer.Deserialize<T>(reader);
}
public T Deserialize<T>(string filePath)
{
using StreamReader reader = File.OpenText(filePath);
return (T)serializer.Deserialize(reader, typeof(T));
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using NitroxModel.Platforms.OS.Shared;
using ProtoBufNet;
using ProtoBufNet.Meta;
namespace NitroxServer.Serialization;
public class ServerProtoBufSerializer : IServerSerializer
{
public const string FILE_ENDING = ".nitrox";
protected RuntimeTypeModel Model { get; } = TypeModel.Create();
public ServerProtoBufSerializer(params string[] assemblies)
{
foreach (string assembly in assemblies)
{
RegisterAssemblyClasses(assembly);
}
}
public string FileEnding => FILE_ENDING;
public void Serialize(Stream stream, object o)
{
Model.SerializeWithLengthPrefix(stream, o, o.GetType(), PrefixStyle.Base128, 0);
}
public void Serialize(string filePath, object o)
{
string tmpPath = Path.ChangeExtension(filePath, ".tmp");
using (Stream stream = File.OpenWrite(tmpPath))
{
Serialize(stream, o);
}
FileSystem.Instance.ReplaceFile(tmpPath, filePath);
}
public T Deserialize<T>(Stream stream)
{
T t = Activator.CreateInstance<T>();
Model.DeserializeWithLengthPrefix(stream, t, typeof(T), PrefixStyle.Base128, 0);
return t;
}
public T Deserialize<T>(string filePath)
{
using Stream stream = File.OpenRead(filePath);
return Deserialize<T>(stream);
}
public void Deserialize(Stream stream, object o, Type t)
{
Model.DeserializeWithLengthPrefix(stream, o, t, PrefixStyle.Base128, 0);
}
private void RegisterAssemblyClasses(string assemblyName)
{
bool HasNitroxDataContract(Type type) => type.GetCustomAttributes(typeof(DataContractAttribute), false).Length > 0;
bool HasNitroxProtoBuf(Type type) => type.GetCustomAttributes(typeof(ProtoContractAttribute), false).Length > 0;
foreach (Type type in Assembly.Load(assemblyName).GetTypes())
{
try
{
if (HasNitroxDataContract(type) || HasNitroxProtoBuf(type))
{
// As of the latest protobuf update they will automatically register detected attributes.
Model.Add(type, true);
}
else if (HasUweProtoContract(type))
{
Model.Add(type, true);
ManuallyRegisterUweProtoMembers(type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), type);
}
}
catch (Exception ex)
{
Log.Error(ex, $"ServerProtoBufSerializer has thrown an error registering the type: {type} from {assemblyName}");
}
}
}
private static bool HasUweProtoContract(Type type)
{
foreach (object o in type.GetCustomAttributes(false))
{
if (o.GetType().ToString().Contains(nameof(ProtoContractAttribute)))
{
return true;
}
}
return false;
}
private void ManuallyRegisterUweProtoMembers(MemberInfo[] info, Type type)
{
foreach (MemberInfo property in info)
{
if (property.DeclaringType != type)
{
continue;
}
foreach (object customAttribute in property.GetCustomAttributes(false))
{
Type attributeType = customAttribute.GetType();
if (attributeType.ToString().Contains(nameof(ProtoMemberAttribute)))
{
int tag = (int)attributeType.GetProperty(nameof(ProtoMemberAttribute.Tag), BindingFlags.Public | BindingFlags.Instance).GetValue(customAttribute, Array.Empty<object>());
Model[type].Add(tag, property.Name);
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Runtime.Serialization;
using NitroxServer.GameLogic.Bases;
using NitroxServer.GameLogic.Entities;
using NitroxServer.GameLogic.Players;
namespace NitroxServer.Serialization.World
{
[DataContract]
public class PersistedWorldData
{
[DataMember(Order = 1)]
public WorldData WorldData { get; set; }
[DataMember(Order = 2)]
public PlayerData PlayerData { get; set; }
[DataMember(Order = 3)]
public GlobalRootData GlobalRootData { get; set; }
[DataMember(Order = 4)]
public EntityData EntityData { get; set; }
public static PersistedWorldData From(World world)
{
return new PersistedWorldData
{
WorldData = new()
{
ParsedBatchCells = world.BatchEntitySpawner.SerializableParsedBatches,
GameData = GameData.From(world.GameData.PDAState, world.GameData.StoryGoals, world.ScheduleKeeper, world.StoryManager, world.TimeKeeper),
Seed = world.Seed,
},
PlayerData = PlayerData.From(world.PlayerManager.GetAllPlayers()),
GlobalRootData = GlobalRootData.From(world.WorldEntityManager.GetPersistentGlobalRootEntities()),
EntityData = EntityData.From(world.EntityRegistry.GetAllEntities(exceptGlobalRoot: true))
};
}
public bool IsValid()
{
return WorldData.IsValid() &&
PlayerData != null &&
GlobalRootData != null &&
EntityData != null;
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Runtime.Serialization;
namespace NitroxServer.Serialization.World;
[DataContract]
public class SaveFileVersion
{
[DataMember(Order = 1)]
public readonly int Major;
[DataMember(Order = 2)]
public readonly int Minor;
[DataMember(Order = 3)]
public readonly int Build;
[DataMember(Order = 4)]
public readonly int Revision;
public Version Version => new(Major, Minor, Build, Revision);
public SaveFileVersion()
{
Major = NitroxEnvironment.Version.Major;
Minor = NitroxEnvironment.Version.Minor;
Build = NitroxEnvironment.Version.Build;
Revision = NitroxEnvironment.Version.Revision;
}
public SaveFileVersion(Version version)
{
Major = version.Major;
Minor = version.Minor;
Build = version.Build;
Revision = version.Revision;
}
public override string ToString()
{
return $"{Major}.{Minor}.{Build}.{Revision}";
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Runtime.Serialization;
namespace NitroxServer.Serialization.World
{
[Serializable]
public class VersionMismatchException : Exception
{
public VersionMismatchException() { }
public VersionMismatchException(string message) : base(message) { }
public VersionMismatchException(string message, Exception inner) : base(message, inner) { }
public override string ToString()
{
return base.Message;
}
}
}

View File

@@ -0,0 +1,28 @@
using NitroxModel.DataStructures.GameLogic;
using NitroxModel.Server;
using NitroxServer.GameLogic;
using NitroxServer.GameLogic.Bases;
using NitroxServer.GameLogic.Entities;
using NitroxServer.GameLogic.Entities.Spawning;
namespace NitroxServer.Serialization.World
{
public class World
{
public PlayerManager PlayerManager { get; set; }
public ScheduleKeeper ScheduleKeeper { get; set; }
public TimeKeeper TimeKeeper { get; set; }
public SimulationOwnershipData SimulationOwnershipData { get; set; }
public EscapePodManager EscapePodManager { get; set; }
public BatchEntitySpawner BatchEntitySpawner { get; set; }
public EntitySimulation EntitySimulation { get; set; }
public EntityRegistry EntityRegistry { get; set; }
public WorldEntityManager WorldEntityManager { get; set; }
public BuildingManager BuildingManager { get; set; }
public StoryManager StoryManager { get; set; }
public GameData GameData { get; set; }
public SessionSettings SessionSettings { get; set; }
public NitroxGameMode GameMode { get; set; }
public string Seed { get; set; }
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using NitroxModel.DataStructures;
using NitroxServer.GameLogic.Bases;
namespace NitroxServer.Serialization.World
{
[DataContract]
public class WorldData
{
[DataMember(Order = 1)]
public List<NitroxInt3> ParsedBatchCells { get; set; } = [];
[DataMember(Order = 2)]
public GameData GameData { get; set; }
[DataMember(Order = 3)]
public string Seed { get; set; }
public bool IsValid()
{
return ParsedBatchCells != null &&
GameData != null &&
Seed != null;
}
}
}

View File

@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NitroxModel.Core;
using NitroxModel.DataStructures;
using NitroxModel.DataStructures.GameLogic;
using NitroxModel.DataStructures.GameLogic.Entities;
using NitroxModel.DataStructures.Util;
using NitroxModel.Networking;
using NitroxModel.Platforms.OS.Shared;
using NitroxModel.Serialization;
using NitroxModel.Server;
using NitroxServer.GameLogic;
using NitroxServer.GameLogic.Bases;
using NitroxServer.GameLogic.Entities;
using NitroxServer.GameLogic.Entities.Spawning;
using NitroxServer.GameLogic.Players;
using NitroxServer.GameLogic.Unlockables;
using NitroxServer.Helper;
using NitroxServer.Resources;
using NitroxServer.Serialization.Upgrade;
namespace NitroxServer.Serialization.World;
public class WorldPersistence
{
public const string BACKUP_DATE_TIME_FORMAT = "yyyy-MM-dd HH.mm.ss";
public IServerSerializer Serializer { get; private set; }
private string FileEnding => Serializer?.FileEnding ?? "";
private readonly ServerProtoBufSerializer protoBufSerializer;
private readonly ServerJsonSerializer jsonSerializer;
private readonly SubnauticaServerConfig config;
private readonly RandomStartGenerator randomStart;
private readonly IWorldModifier worldModifier;
private readonly SaveDataUpgrade[] upgrades;
private readonly RandomSpawnSpoofer randomSpawnSpoofer;
private readonly NtpSyncer ntpSyncer;
public WorldPersistence(
ServerProtoBufSerializer protoBufSerializer,
ServerJsonSerializer jsonSerializer,
SubnauticaServerConfig config,
RandomStartGenerator randomStart,
IWorldModifier worldModifier,
SaveDataUpgrade[] upgrades,
RandomSpawnSpoofer randomSpawnSpoofer,
NtpSyncer ntpSyncer
)
{
this.protoBufSerializer = protoBufSerializer;
this.jsonSerializer = jsonSerializer;
this.config = config;
this.randomStart = randomStart;
this.worldModifier = worldModifier;
this.upgrades = upgrades;
this.randomSpawnSpoofer = randomSpawnSpoofer;
this.ntpSyncer = ntpSyncer;
UpdateSerializer(config.SerializerMode);
}
public bool Save(World world, string saveDir) => Save(PersistedWorldData.From(world), saveDir);
public void BackUp(string saveDir)
{
if (config.MaxBackups < 1)
{
Log.Info($"No backup was made (\"{nameof(config.MaxBackups)}\" is equal to 0)");
return;
}
string backupDir = Path.Combine(saveDir, "Backups");
string tempOutDir = Path.Combine(backupDir, $"Backup - {DateTime.Now.ToString(BACKUP_DATE_TIME_FORMAT)}");
Directory.CreateDirectory(backupDir);
try
{
// Prepare backup location
Directory.CreateDirectory(tempOutDir);
string newZipFile = $"{tempOutDir}.zip";
if (File.Exists(newZipFile))
{
File.Delete(newZipFile);
}
foreach (string file in Directory.GetFiles(saveDir))
{
File.Copy(file, Path.Combine(tempOutDir, Path.GetFileName(file)));
}
FileSystem.Instance.ZipFilesInDirectory(tempOutDir, newZipFile);
Directory.Delete(tempOutDir, true);
Log.Info("World backed up");
// Prune old backups
FileInfo[] backups = Directory.EnumerateFiles(backupDir)
.Select(f => new FileInfo(f))
.Where(f => f is { Extension: ".zip" } info && info.Name.Contains("Backup - "))
.OrderBy(f => File.GetCreationTime(f.FullName))
.ToArray();
if (backups.Length > config.MaxBackups)
{
int numBackupsToDelete = backups.Length - Math.Max(1, config.MaxBackups);
for (int i = 0; i < numBackupsToDelete; i++)
{
backups[i].Delete();
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while backing up world");
if (Directory.Exists(tempOutDir))
{
Directory.Delete(tempOutDir, true); // Delete the outZip folder that is sometimes left
}
}
}
public World Load(string saveName)
{
Optional<World> fileLoadedWorld = LoadFromFile(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), saveName));
if (fileLoadedWorld.HasValue)
{
return fileLoadedWorld.Value;
}
return CreateFreshWorld();
}
public World CreateWorld(PersistedWorldData pWorldData, NitroxGameMode gameMode)
{
string seed = pWorldData.WorldData.Seed;
if (string.IsNullOrWhiteSpace(seed))
{
#if DEBUG
seed = "TCCBIBZXAB";
#else
seed = StringHelper.GenerateRandomString(10);
#endif
}
// Initialized only once, just like UnityEngine.Random
XORRandom.InitSeed(seed.GetHashCode());
Log.Info($"Loading world with seed {seed}");
EntityRegistry entityRegistry = NitroxServiceLocator.LocateService<EntityRegistry>();
entityRegistry.AddEntities(pWorldData.EntityData.Entities);
entityRegistry.AddEntitiesIgnoringDuplicate(pWorldData.GlobalRootData.Entities.OfType<Entity>().ToList());
World world = new()
{
SimulationOwnershipData = new SimulationOwnershipData(),
PlayerManager = new PlayerManager(pWorldData.PlayerData.GetPlayers(), config),
EscapePodManager = new EscapePodManager(entityRegistry, randomStart, seed),
EntityRegistry = entityRegistry,
GameData = pWorldData.WorldData.GameData,
GameMode = gameMode,
Seed = seed,
SessionSettings = new()
};
world.TimeKeeper = new(world.PlayerManager, ntpSyncer, pWorldData.WorldData.GameData.StoryTiming.ElapsedSeconds, pWorldData.WorldData.GameData.StoryTiming.RealTimeElapsed);
world.StoryManager = new StoryManager(world.PlayerManager, pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, seed, pWorldData.WorldData.GameData.StoryTiming.AuroraCountdownTime,
pWorldData.WorldData.GameData.StoryTiming.AuroraWarningTime, pWorldData.WorldData.GameData.StoryTiming.AuroraRealExplosionTime);
world.ScheduleKeeper = new ScheduleKeeper(pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, world.PlayerManager);
world.BatchEntitySpawner = new BatchEntitySpawner(
NitroxServiceLocator.LocateService<EntitySpawnPointFactory>(),
NitroxServiceLocator.LocateService<IUweWorldEntityFactory>(),
NitroxServiceLocator.LocateService<IUwePrefabFactory>(),
pWorldData.WorldData.ParsedBatchCells,
protoBufSerializer,
NitroxServiceLocator.LocateService<IEntityBootstrapperManager>(),
NitroxServiceLocator.LocateService<Dictionary<string, PrefabPlaceholdersGroupAsset>>(),
pWorldData.WorldData.GameData.PDAState,
randomSpawnSpoofer,
world.Seed
);
world.WorldEntityManager = new WorldEntityManager(world.EntityRegistry, world.BatchEntitySpawner, world.PlayerManager);
world.BuildingManager = new BuildingManager(world.EntityRegistry, world.WorldEntityManager, config);
ISimulationWhitelist simulationWhitelist = NitroxServiceLocator.LocateService<ISimulationWhitelist>();
world.EntitySimulation = new EntitySimulation(world.EntityRegistry, world.WorldEntityManager, world.SimulationOwnershipData, world.PlayerManager, simulationWhitelist);
return world;
}
internal void UpdateSerializer(IServerSerializer serverSerializer)
{
Validate.NotNull(serverSerializer, "Serializer cannot be null");
Serializer = serverSerializer;
}
internal void UpdateSerializer(ServerSerializerMode mode) => Serializer = mode == ServerSerializerMode.PROTOBUF ? protoBufSerializer : jsonSerializer;
internal bool Save(PersistedWorldData persistedData, string saveDir)
{
try
{
if (!Directory.Exists(saveDir))
{
Directory.CreateDirectory(saveDir);
}
Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion());
Serializer.Serialize(Path.Combine(saveDir, $"PlayerData{FileEnding}"), persistedData.PlayerData);
Serializer.Serialize(Path.Combine(saveDir, $"WorldData{FileEnding}"), persistedData.WorldData);
Serializer.Serialize(Path.Combine(saveDir, $"GlobalRootData{FileEnding}"), persistedData.GlobalRootData);
Serializer.Serialize(Path.Combine(saveDir, $"EntityData{FileEnding}"), persistedData.EntityData);
using (config.Update(saveDir))
{
config.Seed = persistedData.WorldData.Seed;
}
Log.Info("World state saved");
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Could not save world :");
return false;
}
}
internal Optional<World> LoadFromFile(string saveDir)
{
if (!Directory.Exists(saveDir) || !File.Exists(Path.Combine(saveDir, $"Version{FileEnding}")))
{
Log.Warn("No previous save file found, creating a new one");
return Optional.Empty;
}
UpgradeSave(saveDir);
PersistedWorldData persistedData = LoadDataFromPath(saveDir);
if (persistedData == null)
{
return Optional.Empty;
}
World world = CreateWorld(persistedData, config.GameMode);
return Optional.Of(world);
}
internal PersistedWorldData LoadDataFromPath(string saveDir)
{
try
{
PersistedWorldData persistedData = new()
{
PlayerData = Serializer.Deserialize<PlayerData>(Path.Combine(saveDir, $"PlayerData{FileEnding}")),
WorldData = Serializer.Deserialize<WorldData>(Path.Combine(saveDir, $"WorldData{FileEnding}")),
GlobalRootData = Serializer.Deserialize<GlobalRootData>(Path.Combine(saveDir, $"GlobalRootData{FileEnding}")),
EntityData = Serializer.Deserialize<EntityData>(Path.Combine(saveDir, $"EntityData{FileEnding}"))
};
if (!persistedData.IsValid())
{
throw new InvalidDataException("Save files are not valid");
}
return persistedData;
}
catch (Exception ex)
{
// Check if the world was newly created using the world manager
if (new FileInfo(Path.Combine(saveDir, $"Version{FileEnding}")).Length > 0)
{
// Give error saying that world could not be used, and to restore a backup
Log.Error($"Could not load world, please restore one of your backups to continue using this world. : {ex.GetType()} {ex.Message}");
throw;
}
}
return null;
}
private World CreateFreshWorld()
{
PersistedWorldData pWorldData = new()
{
EntityData = EntityData.From(new List<Entity>()),
PlayerData = PlayerData.From(new List<Player>()),
WorldData = new WorldData
{
GameData = new GameData
{
PDAState = new PDAStateData(),
StoryGoals = new StoryGoalData(),
StoryTiming = new StoryTimingData()
},
ParsedBatchCells = new List<NitroxInt3>(),
Seed = config.Seed
},
GlobalRootData = new GlobalRootData()
};
World newWorld = CreateWorld(pWorldData, config.GameMode);
worldModifier.ModifyWorld(newWorld);
return newWorld;
}
private void UpgradeSave(string saveDir)
{
SaveFileVersion saveFileVersion;
try
{
saveFileVersion = Serializer.Deserialize<SaveFileVersion>(Path.Combine(saveDir, $"Version{FileEnding}"));
}
catch (Exception ex)
{
Log.Error(ex, $"Error while upgrading save file. \"Version{FileEnding}\" couldn't be read.");
return;
}
if (saveFileVersion == null || saveFileVersion.Version == NitroxEnvironment.Version)
{
return;
}
if (config.SerializerMode == ServerSerializerMode.PROTOBUF)
{
Log.Info("Can't upgrade while using ProtoBuf as serializer");
}
else
{
try
{
foreach (SaveDataUpgrade upgrade in upgrades)
{
if (upgrade.TargetVersion > saveFileVersion.Version)
{
upgrade.UpgradeSaveFiles(saveDir, FileEnding);
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while upgrading save file.");
return;
}
Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion());
Log.Info($"Save file was upgraded to {NitroxEnvironment.Version}");
}
}
}