diff --git a/ExtractionTool/Features/MainFeature.cs b/ExtractionTool/Features/MainFeature.cs index 47231a32..eef35438 100644 --- a/ExtractionTool/Features/MainFeature.cs +++ b/ExtractionTool/Features/MainFeature.cs @@ -150,11 +150,16 @@ namespace ExtractionTool.Features sz.Extract(OutputPath, Debug); break; - // Atari 78800 Cart + // Atari 7800 Cart case Atari7800Cart a7800: a7800.Extract(OutputPath, Debug); break; + // Atari Lynx Cart + case AtariLynxCart lynx: + lynx.Extract(OutputPath, Debug); + break; + // BFPK archive case BFPK bfpk: bfpk.Extract(OutputPath, Debug); diff --git a/README.MD b/README.MD index f933e5f7..0daf6290 100644 --- a/README.MD +++ b/README.MD @@ -54,6 +54,7 @@ Options: | --- | --- | | 7-zip archive | .NET Framework 4.6.2 and greater | | Atari 7800 (A78) Cart Image | Header and ROM data | +| Atari Lynx (LNX) Cart Image | Header and ROM data | | BFPK custom archive format | | | bzip2 archive | .NET Framework 4.6.2 and greater | | Compound File Binary (CFB) | Only CFB common pieces extractable | diff --git a/SabreTools.Serialization.Test/Readers/AtariLynxCartTests.cs b/SabreTools.Serialization.Test/Readers/AtariLynxCartTests.cs new file mode 100644 index 00000000..81cb503b --- /dev/null +++ b/SabreTools.Serialization.Test/Readers/AtariLynxCartTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Readers; +using Xunit; + +namespace SabreTools.Serialization.Test.Readers +{ + public class AtariLynxCartTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new AtariLynxCart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization.Test/Wrappers/AtariLynxCartTests.cs b/SabreTools.Serialization.Test/Wrappers/AtariLynxCartTests.cs new file mode 100644 index 00000000..771143ad --- /dev/null +++ b/SabreTools.Serialization.Test/Wrappers/AtariLynxCartTests.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Wrappers; +using Xunit; + +namespace SabreTools.Serialization.Test.Wrappers +{ + public class AtariLynxCartTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = AtariLynxCart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = AtariLynxCart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = AtariLynxCart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = AtariLynxCart.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = AtariLynxCart.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = AtariLynxCart.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization/Extensions/AtariLynxCart.cs b/SabreTools.Serialization/Extensions/AtariLynxCart.cs new file mode 100644 index 00000000..56250d26 --- /dev/null +++ b/SabreTools.Serialization/Extensions/AtariLynxCart.cs @@ -0,0 +1,21 @@ +using SabreTools.Data.Models.AtariLynx; + +namespace SabreTools.Data.Extensions +{ + public static class AtariLynxCart + { + /// + /// Convert a value to string + /// + public static string FromRotation(this Rotation rotation) + { + return rotation switch + { + Rotation.NoRotation => "No rotation (horizontal, buttons right)", + Rotation.RotateLeft => "Rotate left (vertical, buttons down)", + Rotation.RotateRight => "Rotate right (vertical, buttons up)", + _ => $"Unknown {(byte)rotation}", + }; + } + } +} diff --git a/SabreTools.Serialization/Models/AtariLynx/Cart.cs b/SabreTools.Serialization/Models/AtariLynx/Cart.cs new file mode 100644 index 00000000..bd36f808 --- /dev/null +++ b/SabreTools.Serialization/Models/AtariLynx/Cart.cs @@ -0,0 +1,20 @@ +namespace SabreTools.Data.Models.AtariLynx +{ + /// + /// Atari Lynx headered cart (LNX) + /// + /// + public class Cart + { + /// + /// LNX header + /// + /// If omitted, format is technically LYX + public Header? Header { get; set; } + + /// + /// Cartridge data + /// + public byte[] Data { get; set; } = []; + } +} diff --git a/SabreTools.Serialization/Models/AtariLynx/Constants.cs b/SabreTools.Serialization/Models/AtariLynx/Constants.cs new file mode 100644 index 00000000..f59de773 --- /dev/null +++ b/SabreTools.Serialization/Models/AtariLynx/Constants.cs @@ -0,0 +1,15 @@ +namespace SabreTools.Data.Models.AtariLynx +{ + public static class Constants + { + /// + /// LNX magic string + /// + public static readonly byte[] MagicBytes = [0x4C, 0x59, 0x4E, 0x58]; + + /// + /// LNX magic string + /// + public const string MagicString = "LYNX"; + } +} diff --git a/SabreTools.Serialization/Models/AtariLynx/Enums.cs b/SabreTools.Serialization/Models/AtariLynx/Enums.cs new file mode 100644 index 00000000..e0f826f6 --- /dev/null +++ b/SabreTools.Serialization/Models/AtariLynx/Enums.cs @@ -0,0 +1,24 @@ +namespace SabreTools.Data.Models.AtariLynx +{ + /// + /// Screen rotation + /// + /// + public enum Rotation : byte + { + /// + /// No rotation (horizontal, buttons right) + /// + NoRotation = 0, + + /// + /// Rotate left (vertical, buttons down) + /// + RotateLeft = 1, + + /// + /// Rotate right (vertical, buttons up) + /// + RotateRight = 2, + } +} diff --git a/SabreTools.Serialization/Models/AtariLynx/Header.cs b/SabreTools.Serialization/Models/AtariLynx/Header.cs new file mode 100644 index 00000000..954ce3a4 --- /dev/null +++ b/SabreTools.Serialization/Models/AtariLynx/Header.cs @@ -0,0 +1,54 @@ +namespace SabreTools.Data.Models.AtariLynx +{ + /// + /// Atari Lynx emulator header + /// + /// + public class Header + { + /// + /// "LYNX" + /// + /// 4 bytes + public byte[] Magic { get; set; } = new byte[4]; + + /// + /// Page size for bank 0 + /// + public ushort Bank0PageSize { get; set; } + + /// + /// Page size for bank 1 + /// + public ushort Bank1PageSize { get; set; } + + /// + /// Header version + /// + /// Must be 0x0001 + public ushort Version { get; set; } + + /// + /// Game title (Null-padded ASCII) + /// + /// 32 bytes + public byte[] CartName { get; set; } = new byte[32]; + + /// + /// Publisher name + /// + /// 16 bytes + public byte[] Manufacturer { get; set; } = new byte[16]; + + /// + /// Screen rotation + /// + public Rotation Rotation { get; set; } + + /// + /// Padding + /// + /// 5 bytes + public byte[] Spare { get; set; } = new byte[5]; + } +} diff --git a/SabreTools.Serialization/Models/README.MD b/SabreTools.Serialization/Models/README.MD index 382286c6..7c2e6016 100644 --- a/SabreTools.Serialization/Models/README.MD +++ b/SabreTools.Serialization/Models/README.MD @@ -24,6 +24,7 @@ Not all of this information was able to be gathered directly from the files in q | [faydoc.tripod.com](https://faydoc.tripod.com/formats/) | LinearExecutable | | [GuitarGame_ChartFormats](https://github.com/TheNathannator/GuitarGame_ChartFormats/) | Charts | | [HandWiki](https://handwiki.org/wiki/Start) | Quantum | +| [Handy SDL](https://github.com/mozzwald/handy-sdl) | AtariLynxCart | | [HLLib](https://github.com/RavuAlHemio/hllib/) | BSP, GCF, NCF, PAK, SGA, VPK, WAD3, XZP | | [IBM Documentation](https://www.ibm.com/docs/en) | TAR | | [IETF](https://www.ietf.org/) | GZIP | diff --git a/SabreTools.Serialization/Readers/AtariLynxCart.cs b/SabreTools.Serialization/Readers/AtariLynxCart.cs new file mode 100644 index 00000000..e2b0a8c6 --- /dev/null +++ b/SabreTools.Serialization/Readers/AtariLynxCart.cs @@ -0,0 +1,72 @@ +using System.IO; +using SabreTools.Data.Models.AtariLynx; +using SabreTools.IO.Extensions; +using static SabreTools.Data.Models.AtariLynx.Constants; + +namespace SabreTools.Serialization.Readers +{ + public class AtariLynxCart : BaseBinaryReader + { + /// + public override Cart? Deserialize(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long initialOffset = data.Position; + + // Create a new A78 cart image to fill + var cart = new Cart(); + + #region Header + + // Try to parse the header + var header = ParseHeader(data); + if (!header.Magic.EqualsExactly(MagicBytes)) + return null; + else if (header.Version != 0x0001) + return null; + + // Set the header + cart.Header = header; + + #endregion + + // Read the cart data + cart.Data = data.ReadBytes((int)(data.Length - data.Position)); + + return cart; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Stream into a Header + /// + /// Stream to parse + /// Filled Header on success, null on error + public static Header ParseHeader(Stream data) + { + var obj = new Header(); + + obj.Magic = data.ReadBytes(4); + obj.Bank0PageSize = data.ReadUInt16LittleEndian(); + obj.Bank1PageSize = data.ReadUInt16LittleEndian(); + obj.Version = data.ReadUInt16LittleEndian(); + obj.CartName = data.ReadBytes(32); + obj.Manufacturer = data.ReadBytes(16); + obj.Rotation = (Rotation)data.ReadByteValue(); + obj.Spare = data.ReadBytes(5); + + return obj; + } + } +} diff --git a/SabreTools.Serialization/WrapperFactory.cs b/SabreTools.Serialization/WrapperFactory.cs index dbe082ab..4145d353 100644 --- a/SabreTools.Serialization/WrapperFactory.cs +++ b/SabreTools.Serialization/WrapperFactory.cs @@ -16,6 +16,7 @@ namespace SabreTools.Serialization { WrapperType.AACSMediaKeyBlock => AACSMediaKeyBlock.Create(data), WrapperType.Atari7800Cart => Atari7800Cart.Create(data), + WrapperType.AtariLynxCart => AtariLynxCart.Create(data), WrapperType.BDPlusSVM => BDPlusSVM.Create(data), WrapperType.BFPK => BFPK.Create(data), WrapperType.BSP => BSP.Create(data), @@ -193,6 +194,17 @@ namespace SabreTools.Serialization #endregion + #region AtariLynxCart + + // LNX header + if (magic.StartsWith(Data.Models.AtariLynx.Constants.MagicBytes)) + return WrapperType.AtariLynxCart; + + if (extension.Equals("lnx", StringComparison.OrdinalIgnoreCase)) + return WrapperType.AtariLynxCart; + + #endregion + #region BDPlusSVM if (magic.StartsWith(Data.Models.BDPlus.Constants.SignatureBytes)) diff --git a/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs b/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs index da3361c4..e8a0c72a 100644 --- a/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs +++ b/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs @@ -20,7 +20,7 @@ namespace SabreTools.Serialization.Wrappers Print(builder, Model.Header); - //builder.AppendLine(Model.Trainer, "Trainer Data"); + //builder.AppendLine(Model.Data, "ROM Data"); builder.AppendLine(Model.Data.Length, "ROM Data Length"); } diff --git a/SabreTools.Serialization/Wrappers/AtariLynxCart.Extraction.cs b/SabreTools.Serialization/Wrappers/AtariLynxCart.Extraction.cs new file mode 100644 index 00000000..22df3212 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/AtariLynxCart.Extraction.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using SabreTools.Data.Models.AtariLynx; +using SabreTools.IO.Extensions; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class AtariLynxCart : IExtractable + { + /// + public bool Extract(string outputDirectory, bool includeDebug) + { + // Get the base path + string baseFilename = Filename is null + ? Guid.NewGuid().ToString() + : Path.GetFileNameWithoutExtension(Filename); + string basePath = Path.Combine(outputDirectory, baseFilename); + + // Check if any data was extracted successfully + bool success = false; + + // Header data + if (Header is not null) + { + string headerPath = $"{basePath}.hdr"; + if (includeDebug) Console.WriteLine($"Attempting to extract header data to {headerPath}"); + + // Try to write the data + try + { + // Open the output file for writing + using var fs = File.Open(headerPath, FileMode.Create, FileAccess.Write, FileShare.None); + + // Bytes 0-3 + fs.Write(Header.Magic); + fs.Flush(); + + // Bytes 4-5 + fs.Write(Header.Bank0PageSize); + fs.Flush(); + + // Bytes 6-7 + fs.Write(Header.Bank0PageSize); + fs.Flush(); + + // Bytes 8-9 + fs.Write(Header.Version); + fs.Flush(); + + // Bytes 10-41 + fs.Write(Header.CartName); + fs.Flush(); + + // Bytes 42-57 + fs.Write(Header.Manufacturer); + fs.Flush(); + + // Byte 58 + fs.Write((byte)Header.Rotation); + fs.Flush(); + + // Bytes 59-63 + fs.Write(Header.Spare); + fs.Flush(); + + // Header extracted + success = true; + } + catch (Exception ex) + { + if (includeDebug) Console.Error.WriteLine(ex); + } + } + + // ROM data + if (Model.Data.Length > 0) + { + string romPath = $"{basePath}.bin"; + if (includeDebug) Console.WriteLine($"Attempting to extract ROM data to {romPath}"); + + // Try to write the data + try + { + // Open the output file for writing + using var fs = File.Open(romPath, FileMode.Create, FileAccess.Write, FileShare.None); + fs.Write(Model.Data, 0, Model.Data.Length); + fs.Flush(); + + // ROM extracted + success = true; + } + catch (Exception ex) + { + if (includeDebug) Console.Error.WriteLine(ex); + } + } + + return success; + } + } +} diff --git a/SabreTools.Serialization/Wrappers/AtariLynxCart.Printing.cs b/SabreTools.Serialization/Wrappers/AtariLynxCart.Printing.cs new file mode 100644 index 00000000..d7d55907 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/AtariLynxCart.Printing.cs @@ -0,0 +1,55 @@ +using System.Text; +using SabreTools.Data.Extensions; +using SabreTools.Data.Models.AtariLynx; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class AtariLynxCart : IPrintable + { +#if NETCOREAPP + /// + public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions); +#endif + + /// + public void PrintInformation(StringBuilder builder) + { + builder.AppendLine("Atari Lynx Cart Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + Print(builder, Model.Header); + + //builder.AppendLine(Model.Data, "ROM Data"); + builder.AppendLine(Model.Data.Length, "ROM Data Length"); + } + + private static void Print(StringBuilder builder, Header? header) + { + builder.AppendLine(" Header Information:"); + builder.AppendLine(" -------------------------"); + if (header is null) + { + builder.AppendLine(" No header present"); + builder.AppendLine(); + return; + } + + builder.AppendLine(header.Magic, " Magic"); + builder.AppendLine(Encoding.ASCII.GetString(header.Magic).TrimEnd('\0'), " Magic (ASCII)"); + builder.AppendLine(header.Bank0PageSize, " Bank 0 page size"); + builder.AppendLine(header.Bank1PageSize, " Bank 1 page size"); + builder.AppendLine(header.Version, " Version"); + builder.AppendLine(header.CartName, " Cart name"); + builder.AppendLine(Encoding.ASCII.GetString(header.CartName).TrimEnd('\0'), " Cart name (ASCII)"); + builder.AppendLine(header.Manufacturer, " Manufacturer"); + builder.AppendLine(Encoding.ASCII.GetString(header.Manufacturer).TrimEnd('\0'), " Manufacturer (ASCII)"); + + string rotation = header.Rotation.FromRotation(); + builder.AppendLine(rotation, " Rotation"); + + builder.AppendLine(header.Spare, " Padding"); + builder.AppendLine(); + } + } +} diff --git a/SabreTools.Serialization/Wrappers/AtariLynxCart.cs b/SabreTools.Serialization/Wrappers/AtariLynxCart.cs new file mode 100644 index 00000000..cd828a2a --- /dev/null +++ b/SabreTools.Serialization/Wrappers/AtariLynxCart.cs @@ -0,0 +1,120 @@ +using System.IO; +using System.Text; +using SabreTools.Data.Models.AtariLynx; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class AtariLynxCart : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "Atari Lynx Cart Image"; + + #endregion + + #region Extension Properties + + /// + public Header? Header => Model.Header; + + /// + public ushort Bank0PageSize => Header?.Bank0PageSize ?? 0; + + /// + public ushort Bank1PageSize => Header?.Bank1PageSize ?? 0; + + /// + public string? CartName => Header is null + ? null + : Encoding.ASCII.GetString(Header.CartName).TrimEnd('\0'); + + /// + public string? Manufacturer => Header is null + ? null + : Encoding.ASCII.GetString(Header.Manufacturer).TrimEnd('\0'); + + /// + public Rotation Rotation => Header?.Rotation ?? 0; + + /// + public ushort Version => Header?.Version ?? 0; + + #endregion + + #region Constructors + + /// + public AtariLynxCart(Cart model, byte[] data) : base(model, data) { } + + /// + public AtariLynxCart(Cart model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public AtariLynxCart(Cart model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public AtariLynxCart(Cart model, Stream data) : base(model, data) { } + + /// + public AtariLynxCart(Cart model, Stream data, long offset) : base(model, data, offset) { } + + /// + public AtariLynxCart(Cart model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an Atari Lynx cart image from a byte array and offset + /// + /// Byte array representing the archive + /// Offset within the array to parse + /// An Atari Lynx cart image wrapper on success, null on failure + public static AtariLynxCart? Create(byte[]? data, int offset) + { + // If the data is invalid + if (data is null || data.Length == 0) + return null; + + // If the offset is out of bounds + if (offset < 0 || offset >= data.Length) + return null; + + // Create a memory stream and use that + var dataStream = new MemoryStream(data, offset, data.Length - offset); + return Create(dataStream); + } + + /// + /// Create an Atari Lynx cart image from a Stream + /// + /// Stream representing the archive + /// An Atari Lynx cart image wrapper on success, null on failure + public static AtariLynxCart? Create(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long currentOffset = data.Position; + + var model = new Readers.AtariLynxCart().Deserialize(data); + if (model is null) + return null; + + return new AtariLynxCart(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Serialization/Wrappers/WrapperType.cs b/SabreTools.Serialization/Wrappers/WrapperType.cs index e9822d1e..97e463d8 100644 --- a/SabreTools.Serialization/Wrappers/WrapperType.cs +++ b/SabreTools.Serialization/Wrappers/WrapperType.cs @@ -16,10 +16,15 @@ namespace SabreTools.Serialization.Wrappers AACSMediaKeyBlock, /// - /// Atari7800 cart image + /// Atari 7800 cart image /// Atari7800Cart, + /// + /// Atari Lynx cart image + /// + AtariLynxCart, + /// /// BD+ SVM ///