Add Atari Lynx cart support

This commit is contained in:
Matt Nadareski
2026-03-10 14:28:09 -04:00
parent 1909851ed4
commit 2b64d34e7a
17 changed files with 643 additions and 3 deletions

View File

@@ -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);

View File

@@ -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 |

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var deserializer = new AtariLynxCart();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);
}
}
}

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var actual = AtariLynxCart.Create(data);
Assert.Null(actual);
}
}
}

View File

@@ -0,0 +1,21 @@
using SabreTools.Data.Models.AtariLynx;
namespace SabreTools.Data.Extensions
{
public static class AtariLynxCart
{
/// <summary>
/// Convert a <see cref="Rotation"/> value to string
/// </summary>
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}",
};
}
}
}

View File

@@ -0,0 +1,20 @@
namespace SabreTools.Data.Models.AtariLynx
{
/// <summary>
/// Atari Lynx headered cart (LNX)
/// </summary>
/// <see href="https://github.com/mozzwald/handy-sdl/blob/master/src/handy-0.95/cart.h"/>
public class Cart
{
/// <summary>
/// LNX header
/// </summary>
/// <remarks>If omitted, format is technically LYX</remarks>
public Header? Header { get; set; }
/// <summary>
/// Cartridge data
/// </summary>
public byte[] Data { get; set; } = [];
}
}

View File

@@ -0,0 +1,15 @@
namespace SabreTools.Data.Models.AtariLynx
{
public static class Constants
{
/// <summary>
/// LNX magic string
/// </summary>
public static readonly byte[] MagicBytes = [0x4C, 0x59, 0x4E, 0x58];
/// <summary>
/// LNX magic string
/// </summary>
public const string MagicString = "LYNX";
}
}

View File

@@ -0,0 +1,24 @@
namespace SabreTools.Data.Models.AtariLynx
{
/// <summary>
/// Screen rotation
/// </summary>
/// <see href="https://github.com/mozzwald/handy-sdl/blob/master/src/handy-0.95/cart.h"/>
public enum Rotation : byte
{
/// <summary>
/// No rotation (horizontal, buttons right)
/// </summary>
NoRotation = 0,
/// <summary>
/// Rotate left (vertical, buttons down)
/// </summary>
RotateLeft = 1,
/// <summary>
/// Rotate right (vertical, buttons up)
/// </summary>
RotateRight = 2,
}
}

View File

@@ -0,0 +1,54 @@
namespace SabreTools.Data.Models.AtariLynx
{
/// <summary>
/// Atari Lynx emulator header
/// </summary>
/// <see href="https://github.com/mozzwald/handy-sdl/blob/master/src/handy-0.95/cart.h"/>
public class Header
{
/// <summary>
/// "LYNX"
/// </summary>
/// <remarks>4 bytes</remarks>
public byte[] Magic { get; set; } = new byte[4];
/// <summary>
/// Page size for bank 0
/// </summary>
public ushort Bank0PageSize { get; set; }
/// <summary>
/// Page size for bank 1
/// </summary>
public ushort Bank1PageSize { get; set; }
/// <summary>
/// Header version
/// </summary>
/// <remarks>Must be 0x0001</remarks>
public ushort Version { get; set; }
/// <summary>
/// Game title (Null-padded ASCII)
/// </summary>
/// <remarks>32 bytes</remarks>
public byte[] CartName { get; set; } = new byte[32];
/// <summary>
/// Publisher name
/// </summary>
/// <remarks>16 bytes</remarks>
public byte[] Manufacturer { get; set; } = new byte[16];
/// <summary>
/// Screen rotation
/// </summary>
public Rotation Rotation { get; set; }
/// <summary>
/// Padding
/// </summary>
/// <remarks>5 bytes</remarks>
public byte[] Spare { get; set; } = new byte[5];
}
}

View File

@@ -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 |

View File

@@ -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<Cart>
{
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Parse a Stream into a Header
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Header on success, null on error</returns>
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;
}
}
}

View File

@@ -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))

View File

@@ -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");
}

View File

@@ -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
{
/// <inheritdoc/>
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;
}
}
}

View File

@@ -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
/// <inheritdoc/>
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
#endif
/// <inheritdoc/>
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();
}
}
}

View File

@@ -0,0 +1,120 @@
using System.IO;
using System.Text;
using SabreTools.Data.Models.AtariLynx;
namespace SabreTools.Serialization.Wrappers
{
public partial class AtariLynxCart : WrapperBase<Cart>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "Atari Lynx Cart Image";
#endregion
#region Extension Properties
/// <inheritdoc cref="Cart.Header"/>
public Header? Header => Model.Header;
/// <inheritdoc cref="Header.Bank0PageSize"/>
public ushort Bank0PageSize => Header?.Bank0PageSize ?? 0;
/// <inheritdoc cref="Header.Bank1PageSize"/>
public ushort Bank1PageSize => Header?.Bank1PageSize ?? 0;
/// <inheritdoc cref="Header.CartName"/>
public string? CartName => Header is null
? null
: Encoding.ASCII.GetString(Header.CartName).TrimEnd('\0');
/// <inheritdoc cref="Header.Manufacturer"/>
public string? Manufacturer => Header is null
? null
: Encoding.ASCII.GetString(Header.Manufacturer).TrimEnd('\0');
/// <inheritdoc cref="Header.Rotation"/>
public Rotation Rotation => Header?.Rotation ?? 0;
/// <inheritdoc cref="Header.Version"/>
public ushort Version => Header?.Version ?? 0;
#endregion
#region Constructors
/// <inheritdoc/>
public AtariLynxCart(Cart model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public AtariLynxCart(Cart model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public AtariLynxCart(Cart model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public AtariLynxCart(Cart model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public AtariLynxCart(Cart model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public AtariLynxCart(Cart model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create an Atari Lynx cart image from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the archive</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>An Atari Lynx cart image wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create an Atari Lynx cart image from a Stream
/// </summary>
/// <param name="data">Stream representing the archive</param>
/// <returns>An Atari Lynx cart image wrapper on success, null on failure</returns>
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
}
}

View File

@@ -16,10 +16,15 @@ namespace SabreTools.Serialization.Wrappers
AACSMediaKeyBlock,
/// <summary>
/// Atari7800 cart image
/// Atari 7800 cart image
/// </summary>
Atari7800Cart,
/// <summary>
/// Atari Lynx cart image
/// </summary>
AtariLynxCart,
/// <summary>
/// BD+ SVM
/// </summary>