diff --git a/Definitions/ObjectModels/Graphics/ImageTable.cs b/Definitions/ObjectModels/Graphics/ImageTable.cs index 038adfdf..07731d22 100644 --- a/Definitions/ObjectModels/Graphics/ImageTable.cs +++ b/Definitions/ObjectModels/Graphics/ImageTable.cs @@ -1,9 +1,12 @@ +using System.Text.Json.Serialization; + namespace Definitions.ObjectModels.Graphics; public record ImageTableGroup(string Name, List GraphicsElements); public class ImageTable : IHasGraphicsElements { + [JsonIgnore] public PaletteMap PaletteMap { get; diff --git a/Definitions/ObjectModels/LocoObject.cs b/Definitions/ObjectModels/LocoObject.cs index 34fbe03b..402e28b9 100644 --- a/Definitions/ObjectModels/LocoObject.cs +++ b/Definitions/ObjectModels/LocoObject.cs @@ -1,33 +1,16 @@ using Definitions.ObjectModels.Graphics; using Definitions.ObjectModels.Types; +using System.Text.Json.Serialization; -namespace Definitions.ObjectModels; - -public class LocoObject +namespace Definitions.ObjectModels { - public LocoObject(ObjectType objectType, ILocoStruct obj, StringTable stringTable, ImageTable? imageTable = null) + [JsonConverter(typeof(Serialization.LocoObjectJsonConverter))] + public class LocoObject(ObjectType objectType, ILocoStruct obj, StringTable stringTable, ImageTable? imageTable = null) { - ObjectType = objectType; - Object = obj; - StringTable = stringTable; - ImageTable = imageTable; - } + public ObjectType ObjectType { get; init; } = objectType; + public ILocoStruct Object { get; set; } = obj; + public StringTable StringTable { get; set; } = stringTable; - public ObjectType ObjectType { get; init; } - public ILocoStruct Object { get; set; } - public StringTable StringTable { get; set; } - - public ImageTable? ImageTable { get; set; } + public ImageTable? ImageTable { get; set; } = imageTable; + } } - -//public class LocoObjectWithGraphics : LocoObject -//{ -// public LocoObjectWithGraphics(ObjectType objectType, ILocoStruct obj, StringTable stringTable, ImageTable imageTable) -// : base(objectType, obj, stringTable) -// { -// ImageTable = imageTable; -// } - -// public ImageTable ImageTable { get; set; } -//} - diff --git a/Definitions/ObjectModels/Serialization/LocoObjectJsonConverter.cs b/Definitions/ObjectModels/Serialization/LocoObjectJsonConverter.cs new file mode 100644 index 00000000..a4843158 --- /dev/null +++ b/Definitions/ObjectModels/Serialization/LocoObjectJsonConverter.cs @@ -0,0 +1,106 @@ +using Definitions.ObjectModels.Graphics; +using Definitions.ObjectModels.Types; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Definitions.ObjectModels.Serialization +{ + public sealed class LocoObjectJsonConverter : JsonConverter + { + private const string ObjectTypeProp = nameof(LocoObject.ObjectType); + private const string ObjectProp = nameof(LocoObject.Object); + private const string StringTableProp = nameof(LocoObject.StringTable); + private const string ImageTableProp = nameof(LocoObject.ImageTable); + private const string ObjectClrTypeProp = "ObjectClrType"; + + public override LocoObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + // Read required fields + if (!root.TryGetProperty(ObjectTypeProp, out var objTypeEl)) + { + throw new JsonException($"Missing required property '{ObjectTypeProp}'."); + } + + if (!root.TryGetProperty(ObjectProp, out var objEl)) + { + throw new JsonException($"Missing required property '{ObjectProp}'."); + } + + if (!root.TryGetProperty(StringTableProp, out var stringTableEl)) + { + throw new JsonException($"Missing required property '{StringTableProp}'."); + } + + // Determine the concrete ILocoStruct type + if (!root.TryGetProperty(ObjectClrTypeProp, out var clrTypeEl)) + { + throw new JsonException($"Missing required property '{ObjectClrTypeProp}' needed to deserialize '{ObjectProp}'."); + } + + var objectType = objTypeEl.Deserialize(options); + var stringTable = stringTableEl.Deserialize(options) + ?? throw new JsonException($"Could not deserialize '{StringTableProp}'."); + + ImageTable? imageTable = null; + if (root.TryGetProperty(ImageTableProp, out var imageTableEl) && imageTableEl.ValueKind != JsonValueKind.Null) + { + imageTable = imageTableEl.Deserialize(options); + } + + var clrTypeName = clrTypeEl.GetString(); + if (string.IsNullOrWhiteSpace(clrTypeName)) + { + throw new JsonException($"'{ObjectClrTypeProp}' was null or empty."); + } + + var concreteType = Type.GetType(clrTypeName, throwOnError: true) + ?? throw new JsonException($"Could not resolve type '{clrTypeName}'."); + + var locoStruct = (ILocoStruct?)objEl.Deserialize(concreteType, options) + ?? throw new JsonException($"Could not deserialize '{ObjectProp}' as '{concreteType}'."); + + return new LocoObject(objectType, locoStruct, stringTable, imageTable); + } + + public override void Write(Utf8JsonWriter writer, LocoObject value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.Object is null) + { + throw new JsonException("LocoObject.Object was null during serialization."); + } + + writer.WriteStartObject(); + + // ObjectType + writer.WritePropertyName(ObjectTypeProp); + JsonSerializer.Serialize(writer, value.ObjectType, options); + + // Underlying CLR type of the ILocoStruct (prints the correct underlying type) + writer.WriteString(ObjectClrTypeProp, value.Object.GetType().AssemblyQualifiedName); + + // Object (serialize using concrete runtime type) + writer.WritePropertyName(ObjectProp); + JsonSerializer.Serialize(writer, value.Object, value.Object.GetType(), options); + + // StringTable + writer.WritePropertyName(StringTableProp); + JsonSerializer.Serialize(writer, value.StringTable, options); + + // ImageTable (may be null) + writer.WritePropertyName(ImageTableProp); + JsonSerializer.Serialize(writer, value.ImageTable, options); + + writer.WriteEndObject(); + } + } +} + diff --git a/Gui/PlatformSpecific.cs b/Gui/PlatformSpecific.cs index 3d38d749..ed1b62fa 100644 --- a/Gui/PlatformSpecific.cs +++ b/Gui/PlatformSpecific.cs @@ -81,6 +81,7 @@ public static async Task> OpenFolderPicker() }); } + public static readonly IReadOnlyList JsonFileTypes = [new("JSON Files") { Patterns = ["*.json", "*.JSON"] }]; public static readonly IReadOnlyList DatFileTypes = [new("Locomotion DAT Files") { Patterns = ["*.dat", "*.DAT"] }]; public static readonly IReadOnlyList PngFileTypes = [new("PNG Files") { Patterns = ["*.png", "*.PNG"] }]; public static readonly IReadOnlyList SCV5FileTypes = [new("SC5/SV5 Files") { Patterns = ["*.sc5", "*.SC5", "*.sv5", "*.SV5"] }]; diff --git a/Gui/ViewModels/LocoTypes/BaseLocoFileViewModel.cs b/Gui/ViewModels/LocoTypes/BaseLocoFileViewModel.cs index 4cd63af6..889b1f63 100644 --- a/Gui/ViewModels/LocoTypes/BaseLocoFileViewModel.cs +++ b/Gui/ViewModels/LocoTypes/BaseLocoFileViewModel.cs @@ -1,15 +1,26 @@ -using MsBox.Avalonia; -using MsBox.Avalonia.Enums; +using Avalonia.Controls; using Common.Logging; +using Dat.Data; +using Definitions.ObjectModels.Types; using Gui.Models; +using MsBox.Avalonia; +using MsBox.Avalonia.Dto; +using MsBox.Avalonia.Enums; +using MsBox.Avalonia.Models; using ReactiveUI; using ReactiveUI.Fody.Helpers; +using System.Collections.Generic; +using System.Linq; using System.Reactive; using System.Threading.Tasks; -using Definitions.ObjectModels.Types; namespace Gui.ViewModels; +public enum SaveType { JSON, DAT } + +// todo: add filename +public record SaveParameters(SaveType SaveType, SawyerEncoding? SawyerEncoding); + public abstract class BaseLocoFileViewModel : ReactiveObject, ILocoFileViewModel { protected BaseLocoFileViewModel(FileSystemItem currentFile, ObjectEditorModel model) @@ -19,7 +30,7 @@ protected BaseLocoFileViewModel(FileSystemItem currentFile, ObjectEditorModel mo ReloadCommand = ReactiveCommand.Create(Load); SaveCommand = ReactiveCommand.CreateFromTask(SaveWrapper); - SaveAsCommand = ReactiveCommand.Create(SaveAs); + SaveAsCommand = ReactiveCommand.CreateFromTask(SaveAsWrapper); DeleteLocalFileCommand = ReactiveCommand.CreateFromTask(DeleteWrapper); } @@ -36,9 +47,60 @@ protected BaseLocoFileViewModel(FileSystemItem currentFile, ObjectEditorModel mo public abstract void Load(); public abstract void Save(); - public abstract void SaveAs(); + public abstract void SaveAs(SaveParameters saveParameters); public virtual void Delete() { } + async Task SaveAsWrapper() + { + // show save wizard here, asking the user to select a save type (DAT or JSON) and if its DAT, letting them select an option for the DAT encoding + + var buttons = new HashSet() + { + "JSON (Experimental)", + $"DAT ({SawyerEncoding.Uncompressed})", + $"DAT ({SawyerEncoding.RunLengthSingle})", + $"DAT ({SawyerEncoding.RunLengthMulti})", + $"DAT ({SawyerEncoding.Rotate})", + + }; + + var box = MessageBoxManager.GetMessageBoxCustom + (new MessageBoxCustomParams + { + ButtonDefinitions = buttons.Select(x => new ButtonDefinition() { Name = x }), + ContentTitle = "Save As", + ContentMessage = "Save as DAT object or JSON file?", + Icon = Icon.Question, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + //MaxWidth = 500, + MaxHeight = 800, + SizeToContent = SizeToContent.WidthAndHeight, + ShowInCenter = true, + Topmost = false, + }); + + var result = await box.ShowAsync(); + if (!buttons.Contains(result)) + { + return; + } + + var type = result == "JSON (Experimental)" ? SaveType.JSON : SaveType.DAT; + SawyerEncoding? encoding = type == SaveType.DAT + ? result switch + { + "DAT (Uncompressed)" => SawyerEncoding.Uncompressed, + "DAT (RunLengthSingle)" => SawyerEncoding.RunLengthSingle, + "DAT (RunLengthMulti)" => SawyerEncoding.RunLengthMulti, + "DAT (Rotate)" => SawyerEncoding.Rotate, + _ => null + } + : null; + + SaveAs(new SaveParameters(type, encoding)); + } + async Task SaveWrapper() { // note - this is the DAT file source, not the true source... @@ -47,7 +109,7 @@ async Task SaveWrapper() var box = MessageBoxManager.GetMessageBoxStandard("Confirm Save", $"{CurrentFile.FileName} is a vanilla Locomotion file - are you sure you want to overwrite it?", ButtonEnum.YesNo); var result = await box.ShowAsync(); - if (result != ButtonResult.Yes) + if (result == ButtonResult.Yes) { return; } diff --git a/Gui/ViewModels/LocoTypes/G1ViewModel.cs b/Gui/ViewModels/LocoTypes/G1ViewModel.cs index 43fc3520..134e506c 100644 --- a/Gui/ViewModels/LocoTypes/G1ViewModel.cs +++ b/Gui/ViewModels/LocoTypes/G1ViewModel.cs @@ -48,7 +48,7 @@ public override void Save() SawyerStreamWriter.SaveG1(savePath, Model.G1); } - public override void SaveAs() + public override void SaveAs(SaveParameters saveParameters) { if (Model.G1 == null) { diff --git a/Gui/ViewModels/LocoTypes/MusicViewModel.cs b/Gui/ViewModels/LocoTypes/MusicViewModel.cs index ec2d8d1c..17aa6d77 100644 --- a/Gui/ViewModels/LocoTypes/MusicViewModel.cs +++ b/Gui/ViewModels/LocoTypes/MusicViewModel.cs @@ -52,7 +52,7 @@ public override void Save() SaveCore(savePath); } - public override void SaveAs() + public override void SaveAs(SaveParameters saveParameters) { var saveFile = Task.Run(async () => await PlatformSpecific.SaveFilePicker(PlatformSpecific.DatFileTypes)).Result; var savePath = saveFile?.Path.LocalPath; diff --git a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index b38b98bf..0e705417 100644 --- a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs @@ -21,6 +21,7 @@ using System.Reactive; using System.Reactive.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; namespace Gui.ViewModels; @@ -123,7 +124,7 @@ public ObjectEditorViewModel(FileSystemItem currentFile, ObjectEditorModel model } } - public static IObjectViewModel GetViewModelFromStruct(ILocoStruct locoStruct) + public static IObjectViewModel? GetViewModelFromStruct(ILocoStruct locoStruct) { var asm = Assembly .GetExecutingAssembly() @@ -135,7 +136,9 @@ public static IObjectViewModel GetViewModelFromStruct(ILocoStruct locoStruct) && type.BaseType.GetGenericTypeDefinition() == typeof(LocoObjectViewModel<>) && type.BaseType.GenericTypeArguments.Single() == locoStruct.GetType()); - return (IObjectViewModel)Activator.CreateInstance(asm, locoStruct); + return asm == null + ? null + : (IObjectViewModel?)Activator.CreateInstance(asm, locoStruct); } public override void Load() @@ -238,25 +241,29 @@ public override void Save() var savePath = CurrentFile.FileLocation == FileLocation.Local ? CurrentFile.FileName : Path.Combine(Model.Settings.DownloadFolder, Path.ChangeExtension($"{CurrentFile.DisplayName}-{CurrentFile.Id}", ".dat")); - SaveCore(savePath); + SaveCore(savePath, new SaveParameters(SaveType.DAT, ObjectHeaderViewModel?.DatEncoding)); } - public override void SaveAs() - => SaveAsCore(); + public override void SaveAs(SaveParameters saveParameters) + => SaveAsCore(saveParameters); void SaveAsUncompressedDat() - => SaveAsCore(SawyerEncoding.Uncompressed); + => SaveAsCore(new SaveParameters(SaveType.DAT, SawyerEncoding.Uncompressed)); - void SaveAsCore(SawyerEncoding? encodingToUse = null) + void SaveAsCore(SaveParameters saveParameters) { - var saveFile = Task.Run(async () => await PlatformSpecific.SaveFilePicker(PlatformSpecific.DatFileTypes)).Result; + var fileTypes = saveParameters.SaveType == SaveType.JSON + ? PlatformSpecific.JsonFileTypes + : PlatformSpecific.DatFileTypes; + + var saveFile = Task.Run(async () => await PlatformSpecific.SaveFilePicker(fileTypes)).Result; if (saveFile != null) { - SaveCore(saveFile.Path.LocalPath, encodingToUse); + SaveCore(saveFile.Path.LocalPath, saveParameters); } } - void SaveCore(string filename, SawyerEncoding? encodingToUse = null) + void SaveCore(string filename, SaveParameters saveParameters) { if (CurrentObject?.LocoObject == null) { @@ -315,15 +322,34 @@ void SaveCore(string filename, SawyerEncoding? encodingToUse = null) //}; } - var header = CurrentObject.DatFileInfo.S5Header; - - SawyerStreamWriter.Save(filename, - ObjectModelHeaderViewModel?.Name ?? header.Name, - ObjectModelHeaderViewModel?.ObjectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum), - encodingToUse ?? ObjectHeaderViewModel?.DatEncoding ?? SawyerEncoding.Uncompressed, - CurrentObject.LocoObject, - logger, - Model.Settings.AllowSavingAsVanillaObject); + if (saveParameters.SaveType == SaveType.DAT) + { + var header = CurrentObject.DatFileInfo.S5Header; + + SawyerStreamWriter.Save(filename, + ObjectModelHeaderViewModel?.Name ?? header.Name, + ObjectModelHeaderViewModel?.ObjectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum), + saveParameters.SawyerEncoding ?? ObjectHeaderViewModel?.DatEncoding ?? SawyerEncoding.Uncompressed, + CurrentObject.LocoObject, + logger, + Model.Settings.AllowSavingAsVanillaObject); + } + else + { + JsonSerializer.Serialize( + new FileStream(filename, FileMode.Create, FileAccess.Write), + CurrentObject.LocoObject, + new JsonSerializerOptions + { + WriteIndented = true, + //Converters = + //{ + // new LocoStructJsonConverterFactory(), + // new ObjectTypeJsonConverter(), + // new ObjectSourceJsonConverter(), + //} + }); + } } } diff --git a/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs b/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs index 61b1baff..f1bcec89 100644 --- a/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs +++ b/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs @@ -291,12 +291,12 @@ void DrawMap() public override void Save() => logger?.Warning("Save is not currently implemented"); - public override void SaveAs() => logger?.Warning("SaveAs is not currently implemented"); + public override void SaveAs(SaveParameters saveParameters) => logger?.Warning("SaveAs is not currently implemented"); //public override void Save() // => Save(CurrentFile.Filename); - //public override void SaveAs() + //public override void SaveAs(SaveParameters saveParameters) //{ // var saveFile = Task.Run(async () => await PlatformSpecific.SaveFilePicker(PlatformSpecific.SCV5FileTypes)).Result; // if (saveFile == null) diff --git a/Gui/ViewModels/LocoTypes/SoundEffectsViewModel.cs b/Gui/ViewModels/LocoTypes/SoundEffectsViewModel.cs index d81e1d1a..1e90864b 100644 --- a/Gui/ViewModels/LocoTypes/SoundEffectsViewModel.cs +++ b/Gui/ViewModels/LocoTypes/SoundEffectsViewModel.cs @@ -47,7 +47,7 @@ public override void Save() SaveCore(savePath); } - public override void SaveAs() + public override void SaveAs(SaveParameters saveParameters) { var saveFile = Task.Run(async () => await PlatformSpecific.SaveFilePicker(PlatformSpecific.DatFileTypes)).Result; var savePath = saveFile?.Path.LocalPath; diff --git a/Gui/ViewModels/LocoTypes/TutorialViewModel.cs b/Gui/ViewModels/LocoTypes/TutorialViewModel.cs index 7d261856..a1f52d0f 100644 --- a/Gui/ViewModels/LocoTypes/TutorialViewModel.cs +++ b/Gui/ViewModels/LocoTypes/TutorialViewModel.cs @@ -91,7 +91,7 @@ public override void Load() public override void Save() => logger?.Warning("Save is not currently implemented"); - public override void SaveAs() => logger?.Warning("SaveAs is not currently implemented"); + public override void SaveAs(SaveParameters saveParameters) => logger?.Warning("SaveAs is not currently implemented"); } public class ScoresViewModel : BaseLocoFileViewModel @@ -103,7 +103,7 @@ public ScoresViewModel(FileSystemItem currentFile, ObjectEditorModel model) public override void Save() => logger?.Warning("Save is not currently implemented"); - public override void SaveAs() => logger?.Warning("SaveAs is not currently implemented"); + public override void SaveAs(SaveParameters saveParameters) => logger?.Warning("SaveAs is not currently implemented"); } public class LanguageViewModel : BaseLocoFileViewModel @@ -115,5 +115,5 @@ public LanguageViewModel(FileSystemItem currentFile, ObjectEditorModel model) public override void Save() => logger?.Warning("Save is not currently implemented"); - public override void SaveAs() => logger?.Warning("SaveAs is not currently implemented"); + public override void SaveAs(SaveParameters saveParameters) => logger?.Warning("SaveAs is not currently implemented"); } diff --git a/Tests/IdempotenceTests.cs b/Tests/IdempotenceTests.cs index f41b488c..6af6e603 100644 --- a/Tests/IdempotenceTests.cs +++ b/Tests/IdempotenceTests.cs @@ -67,21 +67,24 @@ public void LoadSaveLoad(string filename) using (Assert.EnterMultipleScope()) { + Assert.That(actual, Is.Not.Null); + Assert.That(expected, Is.Not.Null); + Assert.That(JsonSerializer.Serialize((object)actual.Object), Is.EqualTo(JsonSerializer.Serialize((object)expected.Object)), "Object"); Assert.That(JsonSerializer.Serialize(actual.StringTable), Is.EqualTo(JsonSerializer.Serialize(expected.StringTable)), "String Table"); if (actual.ImageTable != null && expected.ImageTable != null) { var i = 0; - foreach (var ae in actual.ImageTable.GraphicsElements.Zip(expected.ImageTable.GraphicsElements)) + foreach (var (First, Second) in actual.ImageTable.GraphicsElements.Zip(expected.ImageTable.GraphicsElements)) { - var ac = JsonSerializer.Serialize(ae.First); - var ex = JsonSerializer.Serialize(ae.Second); + var ac = JsonSerializer.Serialize(First); + var ex = JsonSerializer.Serialize(Second); if (ac != ex) { - _ = PaletteMap.TryConvertG1ToRgba32Bitmap(ae.First, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var img1); - _ = PaletteMap.TryConvertG1ToRgba32Bitmap(ae.Second, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var img2); + _ = PaletteMap.TryConvertG1ToRgba32Bitmap(First, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var img1); + _ = PaletteMap.TryConvertG1ToRgba32Bitmap(Second, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var img2); img1.SaveAsBmp($"{Path.GetFileName(filename)}-{i}-actual.bmp"); img2.SaveAsBmp($"{Path.GetFileName(filename)}-{i}-expected.bmp"); } @@ -100,6 +103,8 @@ public void LoadSaveLoadViewModels(string filename) var obj1 = SawyerStreamReader.LoadFullObject(filename, logger)!.LocoObject!.Object; var vm = ObjectEditorViewModel.GetViewModelFromStruct(obj1); + Assert.That(vm, Is.Not.Null); + var obj2 = vm.GetILocoStruct(); var expected = JsonSerializer.Serialize((object)obj1);