From 4047d8fae590f09edf98e0d0ed41aa5973dcd6dc Mon Sep 17 00:00:00 2001 From: insomnious Date: Tue, 9 Jul 2024 09:56:05 +0100 Subject: [PATCH] Project ready for release Now using System.Text.Json Updated readme Standalone trimmed publish --- DecompressedSaveFile.cs | 290 ++++++++++++++++++++------------------- Example.json | 95 +++++++------ Program.cs | 4 - README.md | 15 +- StarfieldSaveTool.csproj | 2 +- 5 files changed, 199 insertions(+), 207 deletions(-) diff --git a/DecompressedSaveFile.cs b/DecompressedSaveFile.cs index d4ada76..2b9179b 100644 --- a/DecompressedSaveFile.cs +++ b/DecompressedSaveFile.cs @@ -1,58 +1,52 @@ using System.Text; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using NLog; namespace StarfieldSaveTool; -public struct Header -{ - public uint version; - public byte saveVersion; - public uint saveNumber; - [JsonIgnore] public ushort playerNameSize; - public string playerName; - public uint playerLevel; - [JsonIgnore] public ushort playerLocationSize; - public string playerLocation; - [JsonIgnore] public ushort playtimeSize; - public string playtime; - [JsonIgnore] public ushort raceNameSize; - public string raceName; - public ushort gender; - public float experience; - public float experienceRequired; - [JsonIgnore] public ulong time; - public DateTime dateTime; - public uint unknown0; - [JsonIgnore] public byte[] padding; -} -public struct PluginInfo { - - [JsonIgnore] public byte[] Padding; - - public byte PluginCount; - public ushort LightPluginCount; - public uint MediumPluginCount; - - public List Plugins; - public List LightPlugins; - public List MediumPlugins; -} -public abstract class PluginBase +public struct Header { - //public ushort PluginNameSize { get; set; } - public string PluginName { get; set; } + public uint Version { get; set; } + public byte SaveVersion { get; set; } + public uint SaveNumber { get; set; } + [JsonIgnore] public ushort PlayerNameSize { get; set; } + public string PlayerName { get; set; } + public uint PlayerLevel { get; set; } + [JsonIgnore] public ushort PlayerLocationSize { get; set; } + public string PlayerLocation { get; set; } + [JsonIgnore] public ushort PlaytimeSize { get; set; } + public string Playtime { get; set; } + [JsonIgnore] public ushort RaceNameSize { get; set; } + public string RaceName { get; set; } + public ushort Gender { get; set; } + public float Experience { get; set; } + public float ExperienceRequired { get; set; } + [JsonIgnore] public ulong Time { get; set; } + public DateTime DateTime { get; set; } + [JsonIgnore] public uint Unknown0 { get; set; } + [JsonIgnore] public byte[] Padding { get; set; } } -public class Plugin : PluginBase +public struct PluginInfo { - + [JsonIgnore] public byte[] Padding { get; set; } + + public byte PluginCount { get; set; } + public ushort LightPluginCount { get; set; } + public uint MediumPluginCount { get; set; } + + public List Plugins { get; set; } + public List LightPlugins { get; set; } + public List MediumPlugins { get; set; } } -public class ExtendedPlugin : PluginBase +public struct Plugin { + //public ushort PluginNameSize { get; set; } + public string PluginName { get; set; } [JsonIgnore] public ushort CreationNameSize { get; set; } public string CreationName { get; set; } [JsonIgnore] public ushort CreationIdSize { get; set; } @@ -62,98 +56,114 @@ public class ExtendedPlugin : PluginBase [JsonIgnore] public byte AchievementCompatible { get; set; } } +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(DecompressedSaveFile))] +internal partial class SourceGenerationContext : JsonSerializerContext +{ +} + public class DecompressedSaveFile(Stream stream) { - private char[] _magic; - private uint _headerSize; - [JsonProperty] private Header _header; - [JsonProperty] private byte saveVersion; - private ushort currentGameVersionSize; - [JsonProperty] private string currentGameVersion; - private ushort createdGameVersionSize; - [JsonProperty] private string createdGameVersion; - private ushort pluginInfoSize; - [JsonProperty] private PluginInfo _pluginInfo; - + [JsonIgnore] public char[] Magic { get; private set; } + [JsonIgnore] public uint HeaderSize { get; private set; } + public Header Header { get; private set; } + public byte SaveVersion { get; private set; } + [JsonIgnore] public ushort CurrentGameVersionSize { get; private set; } + public string CurrentGameVersion { get; private set; } = ""; + [JsonIgnore] public ushort CreatedGameVersionSize { get; private set; } + public string CreatedGameVersion { get; private set; } = ""; + [JsonIgnore] public ushort PluginInfoSize { get; private set; } + public PluginInfo PluginInfo { get; private set; } + private Stream _stream = stream; private Logger _logger = LogManager.GetCurrentClassLogger(); - + const string SAVE_MAGIC = "SFS_SAVEGAME"; - readonly string[] NATIVE_PLUGINS = { "Starfield.esm", "Constellation.esm", "OldMars.esm", "BlueprintShips-Starfield.esm", "SFBGS007.esm", "SFBGS008.esm", "SFBGS006.esm", "SFBGS003.esm" }; - + + readonly string[] NATIVE_PLUGINS = + { + "Starfield.esm", "Constellation.esm", "OldMars.esm", "BlueprintShips-Starfield.esm", "SFBGS007.esm", + "SFBGS008.esm", "SFBGS006.esm", "SFBGS003.esm" + }; + public void ReadFile() { using var br = new BinaryReader(_stream); br.BaseStream.Seek(0, SeekOrigin.Begin); // quick check for magic bytes - _magic = br.ReadChars(12); - - if (new string(_magic) != SAVE_MAGIC) + Magic = br.ReadChars(12); + + if (new string(Magic) != SAVE_MAGIC) { _logger.Error("Invalid file format"); throw new Exception($"Not a valid decompressed Starfield save. Magic bytes not found."); } - - _headerSize = br.ReadUInt32(); - - _header = ReadHeader(br); - - saveVersion = br.ReadByte(); - currentGameVersionSize = br.ReadUInt16(); - currentGameVersion = Encoding.ASCII.GetString(br.ReadBytes(currentGameVersionSize)); - createdGameVersionSize = br.ReadUInt16(); - createdGameVersion = Encoding.ASCII.GetString(br.ReadBytes(createdGameVersionSize)); - pluginInfoSize = br.ReadUInt16(); - - _pluginInfo = ReadPluginInfo(br, saveVersion); + + HeaderSize = br.ReadUInt32(); + + Header = ReadHeader(br); + + SaveVersion = br.ReadByte(); + CurrentGameVersionSize = br.ReadUInt16(); + CurrentGameVersion = Encoding.ASCII.GetString(br.ReadBytes(CurrentGameVersionSize)); + CreatedGameVersionSize = br.ReadUInt16(); + CreatedGameVersion = Encoding.ASCII.GetString(br.ReadBytes(CreatedGameVersionSize)); + PluginInfoSize = br.ReadUInt16(); + + PluginInfo = ReadPluginInfo(br, SaveVersion); } public string ToJson() { - var json = JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); - return json; + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = SourceGenerationContext.Default + }; + return JsonSerializer.Serialize(this, options); } - - + + private PluginInfo ReadPluginInfo(BinaryReader br, byte infoSaveVersion) { - _pluginInfo = new PluginInfo(); + var pluginInfo = new PluginInfo(); + + pluginInfo.Padding = br.ReadBytes(2); + pluginInfo.PluginCount = br.ReadByte(); - _pluginInfo.Padding = br.ReadBytes(2); - _pluginInfo.PluginCount = br.ReadByte(); - - _pluginInfo.Plugins = new List(); - _pluginInfo.LightPlugins = new List(); - _pluginInfo.MediumPlugins = new List(); + pluginInfo.Plugins = new List(); + pluginInfo.LightPlugins = new List(); + pluginInfo.MediumPlugins = new List(); // loop through normal plugins - for (int i = 0; i < _pluginInfo.PluginCount; i++) + for (int i = 0; i < pluginInfo.PluginCount; i++) { - _pluginInfo.Plugins.Add(ReadPlugin(br)); + pluginInfo.Plugins.Add(ReadPlugin(br)); } - - _pluginInfo.LightPluginCount = br.ReadUInt16(); - + + pluginInfo.LightPluginCount = br.ReadUInt16(); + // loop through light plugins - for (int i = 0; i < _pluginInfo.LightPluginCount; i++) + for (int i = 0; i < pluginInfo.LightPluginCount; i++) { - _pluginInfo.LightPlugins.Add(ReadPlugin(br)); + pluginInfo.LightPlugins.Add(ReadPlugin(br)); } // previous save versions didn't have medium plugins if (infoSaveVersion >= 122) { - _pluginInfo.MediumPluginCount = br.ReadUInt32(); + pluginInfo.MediumPluginCount = br.ReadUInt32(); // loop through medium plugins - for (int i = 0; i < _pluginInfo.MediumPluginCount; i++) + for (int i = 0; i < pluginInfo.MediumPluginCount; i++) { - _pluginInfo.MediumPlugins.Add(ReadPlugin(br)); + pluginInfo.MediumPlugins.Add(ReadPlugin(br)); } } - return _pluginInfo; + return pluginInfo; } string ReadString(BinaryReader br) @@ -161,28 +171,26 @@ string ReadString(BinaryReader br) // read the string from current position // made up of an ushort for the size of the string and then the string itself var size = br.ReadUInt16(); - return Encoding.ASCII.GetString(br.ReadBytes(size)); + return Encoding.ASCII.GetString(br.ReadBytes(size)); } - private PluginBase ReadPlugin(BinaryReader br) + private Plugin ReadPlugin(BinaryReader br) { // record the current position // var offset = br.BaseStream.Position; + var plugin = new Plugin(); + // read the plugin name - var pluginName = ReadString(br); + plugin.PluginName = ReadString(br); // reset the position //br.BaseStream.Seek(offset, SeekOrigin.Begin); - if (NATIVE_PLUGINS.Contains(pluginName)) + if (NATIVE_PLUGINS.Contains(plugin.PluginName)) { - _logger.Info($"{pluginName} is a native plugin."); - - return new Plugin - { - PluginName = pluginName - }; + _logger.Info($"{plugin.PluginName} is a native plugin."); + return plugin; } /* @@ -192,55 +200,51 @@ private PluginBase ReadPlugin(BinaryReader br) // reset position br.BaseStream.Seek(-2, SeekOrigin.Current); */ - + // non-native plugin so we are expecting some extra info and possibly creation info - // creation plugin - var extendedPlugin = new ExtendedPlugin(); - extendedPlugin.PluginName = pluginName; - // creation name not always here - extendedPlugin.CreationNameSize = br.ReadUInt16(); - if(extendedPlugin.CreationNameSize != 0) - extendedPlugin.CreationName = Encoding.ASCII.GetString(br.ReadBytes(extendedPlugin.CreationNameSize)); - + plugin.CreationNameSize = br.ReadUInt16(); + if (plugin.CreationNameSize != 0) + plugin.CreationName = Encoding.ASCII.GetString(br.ReadBytes(plugin.CreationNameSize)); + // creation id not always here - extendedPlugin.CreationIdSize = br.ReadUInt16(); - if(extendedPlugin.CreationIdSize != 0) - extendedPlugin.CreationId = Encoding.ASCII.GetString(br.ReadBytes(extendedPlugin.CreationIdSize)); - - extendedPlugin.FlagsSize = br.ReadUInt16(); - extendedPlugin.Flags = br.ReadBytes(extendedPlugin.FlagsSize); - extendedPlugin.AchievementCompatible = br.ReadByte(); - - _logger.Info($"{pluginName} is an extended plugin ({extendedPlugin.CreationName})."); - return extendedPlugin; + plugin.CreationIdSize = br.ReadUInt16(); + if (plugin.CreationIdSize != 0) + plugin.CreationId = Encoding.ASCII.GetString(br.ReadBytes(plugin.CreationIdSize)); + + plugin.FlagsSize = br.ReadUInt16(); + plugin.Flags = br.ReadBytes(plugin.FlagsSize); + plugin.AchievementCompatible = br.ReadByte(); + + _logger.Info($"{plugin.PluginName} is a normal plugin ({plugin.CreationName})."); + return plugin; } static Header ReadHeader(BinaryReader br) { var header = new Header(); - - header.version = br.ReadUInt32(); - header.saveVersion = br.ReadByte(); - header.saveNumber = br.ReadUInt32(); - header.playerNameSize = br.ReadUInt16(); - header.playerName = Encoding.ASCII.GetString(br.ReadBytes(header.playerNameSize)); - header.playerLevel = br.ReadUInt32(); - header.playerLocationSize = br.ReadUInt16(); - header.playerLocation = Encoding.ASCII.GetString(br.ReadBytes(header.playerLocationSize)); - header.playtimeSize = br.ReadUInt16(); - header.playtime = Encoding.ASCII.GetString(br.ReadBytes(header.playtimeSize)); - header.raceNameSize = br.ReadUInt16(); - header.raceName = Encoding.ASCII.GetString(br.ReadBytes(header.raceNameSize)); - header.gender = br.ReadUInt16(); - header.experience = br.ReadSingle(); - header.experienceRequired = br.ReadSingle(); - header.time = br.ReadUInt64(); - header.dateTime = DateTime.FromFileTimeUtc((long)header.time); - header.unknown0 = br.ReadUInt32(); - header.padding = br.ReadBytes(8); - + + header.Version = br.ReadUInt32(); + header.SaveVersion = br.ReadByte(); + header.SaveNumber = br.ReadUInt32(); + header.PlayerNameSize = br.ReadUInt16(); + header.PlayerName = Encoding.ASCII.GetString(br.ReadBytes(header.PlayerNameSize)); + header.PlayerLevel = br.ReadUInt32(); + header.PlayerLocationSize = br.ReadUInt16(); + header.PlayerLocation = Encoding.ASCII.GetString(br.ReadBytes(header.PlayerLocationSize)); + header.PlaytimeSize = br.ReadUInt16(); + header.Playtime = Encoding.ASCII.GetString(br.ReadBytes(header.PlaytimeSize)); + header.RaceNameSize = br.ReadUInt16(); + header.RaceName = Encoding.ASCII.GetString(br.ReadBytes(header.RaceNameSize)); + header.Gender = br.ReadUInt16(); + header.Experience = br.ReadSingle(); + header.ExperienceRequired = br.ReadSingle(); + header.Time = br.ReadUInt64(); + header.DateTime = DateTime.FromFileTimeUtc((long)header.Time); + header.Unknown0 = br.ReadUInt32(); + header.Padding = br.ReadBytes(8); + return header; } } \ No newline at end of file diff --git a/Example.json b/Example.json index d4f6b3f..baa1c4e 100644 --- a/Example.json +++ b/Example.json @@ -1,28 +1,25 @@ { - "_header": { - "version": 27, - "saveVersion": 122, - "saveNumber": 1, - "playerName": "Player", - "playerLevel": 1, - "playerLocation": "Volii Alpha - Volii Alpha", - "playtime": "0d.0h.4m.0 days.0 hours.4 minutes", - "raceName": "HumanRace", - "gender": 0, - "experience": 40.0, - "experienceRequired": 200.0, - "dateTime": "2024-07-01T22:48:15.3971934Z", - "unknown0": 0 + "Header": { + "Version": 27, + "SaveVersion": 122, + "SaveNumber": 1, + "PlayerName": "Player", + "PlayerLevel": 1, + "PlayerLocation": "Volii Alpha - Madame Sauvage\u0027s Place", + "Playtime": "0d.0h.4m.0 days.0 hours.4 minutes", + "RaceName": "HumanRace", + "Gender": 0, + "Experience": 40, + "ExperienceRequired": 200, + "DateTime": "2024-06-27T22:57:26.0368839Z" }, - "_info": { - "saveVersion": 122, - "currentGameVersion": "1.12.32.0", - "createdGameVersion": "1.12.32.0" - }, - "_pluginInfo": { + "SaveVersion": 122, + "CurrentGameVersion": "1.12.32.0", + "CreatedGameVersion": "1.12.32.0", + "PluginInfo": { "PluginCount": 15, - "LightPluginCount": 18, - "MediumPluginCount": 6, + "LightPluginCount": 17, + "MediumPluginCount": 7, "Plugins": [ { "PluginName": "Starfield.esm" @@ -31,9 +28,9 @@ "PluginName": "BlueprintShips-Starfield.esm" }, { + "PluginName": "tankgirlsmodelhome.esm", "CreationName": "TGs Luxury Homes Volume I", - "CreationId": "TM_3232667a-c443-48a1-8719-1d3978bc4cd9", - "PluginName": "tankgirlsmodelhome.esm" + "CreationId": "TM_3232667a-c443-48a1-8719-1d3978bc4cd9" }, { "PluginName": "StarfieldCommunityPatch.esm" @@ -57,7 +54,7 @@ "PluginName": "Ship Power Fix.esm" }, { - "PluginName": "Starfarer's Hairstyles.esm" + "PluginName": "Starfarer\u0027s Hairstyles.esm" }, { "PluginName": "DK-Follower.esp" @@ -86,44 +83,39 @@ "PluginName": "SFBGS008.esm" }, { + "PluginName": "sfbgs00a_a.esm", "CreationName": "Blackout Drumbeat Skin", - "CreationId": "TM_31ccf130-4852-417b-842a-9d82672028e4", - "PluginName": "sfbgs00a_a.esm" + "CreationId": "TM_31ccf130-4852-417b-842a-9d82672028e4" }, { + "PluginName": "sfbgs021.esm", "CreationName": "Observatory", - "CreationId": "TM_e8ff65fc-7c13-44b3-b8a5-27df6b28007d", - "PluginName": "sfbgs021.esm" + "CreationId": "TM_e8ff65fc-7c13-44b3-b8a5-27df6b28007d" }, { + "PluginName": "sfbgs023.esm", "CreationName": "Starborn Gravis Suit", - "CreationId": "TM_beefc7ae-59f4-4934-b2a5-d04e5264f029", - "PluginName": "sfbgs023.esm" + "CreationId": "TM_beefc7ae-59f4-4934-b2a5-d04e5264f029" }, { + "PluginName": "unocucflygirl.esm", "CreationName": "Robin Locke - UC Fly Girl Companion", - "CreationId": "TM_31bc272e-edc6-4d27-a420-2d6d5fd99f79", - "PluginName": "unocucflygirl.esm" + "CreationId": "TM_31bc272e-edc6-4d27-a420-2d6d5fd99f79" }, { + "PluginName": "ringobungo_apexelectronicsex.esm", "CreationName": "Desktop Speaker (SSNN Broadcast)", - "CreationId": "TM_9abcd3bf-ba88-4140-9746-8d02b68b3474", - "PluginName": "ringobungo_apexelectronicsex.esm" + "CreationId": "TM_9abcd3bf-ba88-4140-9746-8d02b68b3474" }, { + "PluginName": "neonsigns.esm", "CreationName": "Realistic Neon Signs", - "CreationId": "TM_7c0ecaca-8647-4f2f-833a-ff2e76aadd5c", - "PluginName": "neonsigns.esm" + "CreationId": "TM_7c0ecaca-8647-4f2f-833a-ff2e76aadd5c" }, { + "PluginName": "betterqasmoke.esm", "CreationName": "Qasmoke has workbenches", - "CreationId": "TM_0a88c365-4a7e-4377-ae18-fbe0e37df40b", - "PluginName": "betterqasmoke.esm" - }, - { - "CreationName": "FaceReDesign", - "CreationId": "TM_78cf4166-6c37-4653-a363-9a23c8d261cd", - "PluginName": "FaceRedesign.esm" + "CreationId": "TM_0a88c365-4a7e-4377-ae18-fbe0e37df40b" }, { "PluginName": "GalBankPlus.esm" @@ -152,14 +144,19 @@ "PluginName": "SFBGS003.esm" }, { + "PluginName": "sfta01.esm", "CreationName": "Trackers Alliance: The Vulture", - "CreationId": "TM_4bf1a31f-46d5-47bf-a5e6-d0f9e2496d0c", - "PluginName": "sfta01.esm" + "CreationId": "TM_4bf1a31f-46d5-47bf-a5e6-d0f9e2496d0c" }, { + "PluginName": "qog-miningconglomerate.esm", "CreationName": "StarSim: Mining Conglomerate", - "CreationId": "TM_e0004076-6d5f-4b96-b7c9-c257a0e3b892", - "PluginName": "qog-miningconglomerate.esm" + "CreationId": "TM_e0004076-6d5f-4b96-b7c9-c257a0e3b892" + }, + { + "PluginName": "FaceRedesign.esm", + "CreationName": "FaceReDesign", + "CreationId": "TM_78cf4166-6c37-4653-a363-9a23c8d261cd" }, { "PluginName": "StarValor.esm" @@ -169,4 +166,4 @@ } ] } -} \ No newline at end of file +} diff --git a/Program.cs b/Program.cs index 2fe9659..5f0d33f 100644 --- a/Program.cs +++ b/Program.cs @@ -3,9 +3,6 @@ using System.Diagnostics; using System.Text; using Ionic.Zlib; -using System.Xml; -using Newtonsoft.Json; -using CompressionMode = Ionic.Zlib.CompressionMode; namespace StarfieldSaveTool; @@ -29,7 +26,6 @@ struct SfsFileHeader } - class Program { private static Logger logger; diff --git a/README.md b/README.md index c15a18d..668fca4 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ A tool to decompress and convert Starfield save games to JSON format. Save games Any help with the file format would be appreciated, the majority of unknowns are in the header of the compressed `sfs` file, and the plugins data within the decompressed file. I'm not including the main data blocks of the decompressed file -as -we are primarily interested in the metadata. +as we are primarily interested in the metadata. Thanks to Mod Organizer 2 for it's help to get started writing this tool and to help formalize this file format research. Special thanks to Silarn for the reverse engineering help. @@ -149,21 +148,17 @@ Medium plugins were added in save file version 122. They are stored after the li ### PLUGIN -There are 2 different types of Plugin data blocks. +There are different types of Plugin data blocks. -* Base Plugins consists of just the plugin name, normally just native game plugins. -* Extended Plugins are Base Plugins with extra info. - -### Base Plugin +* Native game plugins only contain the pluginName +* Non-native plugins contain extra data, including Creation information and flags where appropriate. | Name | Type | Description | |----------------|----------|---------------------| | pluginNameSize | `ushort` | Size of plugin name | | pluginName | `string` | Plugin name | -### Extended Plugin - -Includes the Base Plugin data and the following: +#### Extra Data | Name | Type | Description | |-----------------------|-------------------|------------------------------------------------------| diff --git a/StarfieldSaveTool.csproj b/StarfieldSaveTool.csproj index f5ef6a0..f64c2d8 100644 --- a/StarfieldSaveTool.csproj +++ b/StarfieldSaveTool.csproj @@ -5,11 +5,11 @@ net8.0 enable enable + false -