From e2581fc42324580b085201df0691b1d51bc47e2f Mon Sep 17 00:00:00 2001 From: Matt Nadareski Date: Sat, 7 Mar 2026 21:27:11 -0500 Subject: [PATCH] Add Atari 7800 cart support --- ExtractionTool/Features/MainFeature.cs | 5 + InfoPrint/Features/MainFeature.cs | 4 +- README.MD | 1 + .../Readers/Atari7800CartTests.cs | 73 ++++ .../Wrappers/Atari7800CartTests.cs | 61 +++ .../Extensions/Atari7800Cart.cs | 327 +++++++++++++++ .../Extensions/NESCart.cs | 2 +- .../Models/Atari7800/Cart.cs | 20 + .../Models/Atari7800/Constants.cs | 49 +++ .../Models/Atari7800/Enums.cs | 380 ++++++++++++++++++ .../Models/Atari7800/Header.cs | 115 ++++++ SabreTools.Serialization/Models/README.MD | 1 + .../Readers/Atari7800Cart.cs | 96 +++++ SabreTools.Serialization/Readers/NESCart.cs | 2 +- SabreTools.Serialization/Readers/SkuSis.cs | 8 +- SabreTools.Serialization/WrapperFactory.cs | 25 ++ .../Wrappers/Atari7800Cart.Extraction.cs | 143 +++++++ .../Wrappers/Atari7800Cart.Printing.cs | 91 +++++ .../Wrappers/Atari7800Cart.cs | 147 +++++++ SabreTools.Serialization/Wrappers/SkuSis.cs | 6 +- .../Wrappers/WrapperType.cs | 5 + 21 files changed, 1550 insertions(+), 11 deletions(-) create mode 100644 SabreTools.Serialization.Test/Readers/Atari7800CartTests.cs create mode 100644 SabreTools.Serialization.Test/Wrappers/Atari7800CartTests.cs create mode 100644 SabreTools.Serialization/Extensions/Atari7800Cart.cs create mode 100644 SabreTools.Serialization/Models/Atari7800/Cart.cs create mode 100644 SabreTools.Serialization/Models/Atari7800/Constants.cs create mode 100644 SabreTools.Serialization/Models/Atari7800/Enums.cs create mode 100644 SabreTools.Serialization/Models/Atari7800/Header.cs create mode 100644 SabreTools.Serialization/Readers/Atari7800Cart.cs create mode 100644 SabreTools.Serialization/Wrappers/Atari7800Cart.Extraction.cs create mode 100644 SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs create mode 100644 SabreTools.Serialization/Wrappers/Atari7800Cart.cs diff --git a/ExtractionTool/Features/MainFeature.cs b/ExtractionTool/Features/MainFeature.cs index 0ce98f51..47231a32 100644 --- a/ExtractionTool/Features/MainFeature.cs +++ b/ExtractionTool/Features/MainFeature.cs @@ -150,6 +150,11 @@ namespace ExtractionTool.Features sz.Extract(OutputPath, Debug); break; + // Atari 78800 Cart + case Atari7800Cart a7800: + a7800.Extract(OutputPath, Debug); + break; + // BFPK archive case BFPK bfpk: bfpk.Extract(OutputPath, Debug); diff --git a/InfoPrint/Features/MainFeature.cs b/InfoPrint/Features/MainFeature.cs index efb694e2..668adcde 100644 --- a/InfoPrint/Features/MainFeature.cs +++ b/InfoPrint/Features/MainFeature.cs @@ -158,8 +158,8 @@ namespace InfoPrint.Features { using Stream stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - // Read the first 8 bytes - byte[] magic = stream.PeekBytes(8); + // Read the first 16 bytes + byte[] magic = stream.PeekBytes(16); // Get the file type string extension = Path.GetExtension(file).TrimStart('.'); diff --git a/README.MD b/README.MD index 914ca61f..f933e5f7 100644 --- a/README.MD +++ b/README.MD @@ -53,6 +53,7 @@ Options: | Format Name | Notes | | --- | --- | | 7-zip archive | .NET Framework 4.6.2 and greater | +| Atari 7800 (A78) 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/Atari7800CartTests.cs b/SabreTools.Serialization.Test/Readers/Atari7800CartTests.cs new file mode 100644 index 00000000..67d96097 --- /dev/null +++ b/SabreTools.Serialization.Test/Readers/Atari7800CartTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Readers; +using Xunit; + +namespace SabreTools.Serialization.Test.Readers +{ + public class Atari7800CartTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new Atari7800Cart(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new Atari7800Cart(); + + 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 Atari7800Cart(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new Atari7800Cart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new Atari7800Cart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new Atari7800Cart(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization.Test/Wrappers/Atari7800CartTests.cs b/SabreTools.Serialization.Test/Wrappers/Atari7800CartTests.cs new file mode 100644 index 00000000..9f44c2a3 --- /dev/null +++ b/SabreTools.Serialization.Test/Wrappers/Atari7800CartTests.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Wrappers; +using Xunit; + +namespace SabreTools.Serialization.Test.Wrappers +{ + public class Atari7800CartTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = Atari7800Cart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = Atari7800Cart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = Atari7800Cart.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = Atari7800Cart.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = Atari7800Cart.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = Atari7800Cart.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization/Extensions/Atari7800Cart.cs b/SabreTools.Serialization/Extensions/Atari7800Cart.cs new file mode 100644 index 00000000..4f1c1283 --- /dev/null +++ b/SabreTools.Serialization/Extensions/Atari7800Cart.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using SabreTools.Data.Models.Atari7800; + +namespace SabreTools.Data.Extensions +{ + public static class Atari7800Cart + { + /// + /// Convert a value to string + /// + public static string FromAudioDevice(this AudioDevice audio) + { + string[] devices = new string[4]; + + byte pokey = (byte)((byte)audio & 0x0F); + devices[0] = pokey switch + { + 0 => "POKEY - none", + 1 => "POKEY - @440", + 2 => "POKEY - @450", + 3 => "POKEY - @450+@440", + 4 => "POKEY - @800", + 5 => "POKEY - @4000", + _ => $"Unknown {pokey}", + }; + + byte ym2151 = (byte)(((byte)audio >> 1) & 0x01); + devices[1] = ym2151 switch + { + 0 => "No YM2151 @460", + 1 => "YM2151 @460", + _ => $"Unknown {ym2151}", + }; + + byte covox = (byte)(((byte)audio >> 2) & 0x01); + devices[2] = covox switch + { + 0 => "No COVOX @430", + 1 => "COVOX @430", + _ => $"Unknown {covox}", + }; + + byte adpcm = (byte)(((byte)audio >> 3) & 0x01); + devices[3] = adpcm switch + { + 0 => "No ADPCM Audio Stream @420", + 1 => "ADPCM Audio Stream @420", + _ => $"Unknown {adpcm}", + }; + + return string.Join(", ", [.. devices]); + } + + /// + /// Convert a value to string + /// + public static string FromCartType(this CartType type) + { + List types = []; + +#if NET20 || NET35 + if ((type & CartType.PokeyAt4000) != 0) +#else + if (type.HasFlag(CartType.PokeyAt4000)) +#endif + types.Add("pokey at $4000"); + +#if NET20 || NET35 + if ((type & CartType.SupergameBankSwitched) != 0) +#else + if (type.HasFlag(CartType.SupergameBankSwitched)) +#endif + types.Add("supergame bank switched"); + +#if NET20 || NET35 + if ((type & CartType.SupergameRamAt4000) != 0) +#else + if (type.HasFlag(CartType.SupergameRamAt4000)) +#endif + types.Add("supergame ram at $4000"); + +#if NET20 || NET35 + if ((type & CartType.RomAt4000) != 0) +#else + if (type.HasFlag(CartType.RomAt4000)) +#endif + types.Add("rom at $4000"); + +#if NET20 || NET35 + if ((type & CartType.Bank6At4000) != 0) +#else + if (type.HasFlag(CartType.Bank6At4000)) +#endif + types.Add("bank 6 at $4000"); + +#if NET20 || NET35 + if ((type & CartType.BankedRam) != 0) +#else + if (type.HasFlag(CartType.BankedRam)) +#endif + types.Add("banked ram"); + +#if NET20 || NET35 + if ((type & CartType.PokeyAt450) != 0) +#else + if (type.HasFlag(CartType.PokeyAt450)) +#endif + types.Add("pokey at $450"); + +#if NET20 || NET35 + if ((type & CartType.MirrorRamAt4000) != 0) +#else + if (type.HasFlag(CartType.MirrorRamAt4000)) +#endif + types.Add("mirror ram at $4000"); + +#if NET20 || NET35 + if ((type & CartType.ActivisionBanking) != 0) +#else + if (type.HasFlag(CartType.ActivisionBanking)) +#endif + types.Add("activision banking"); + +#if NET20 || NET35 + if ((type & CartType.AbsoluteBanking) != 0) +#else + if (type.HasFlag(CartType.AbsoluteBanking)) +#endif + types.Add("absolute banking"); + +#if NET20 || NET35 + if ((type & CartType.PokeyAt440) != 0) +#else + if (type.HasFlag(CartType.PokeyAt440)) +#endif + types.Add("pokey at $440"); + +#if NET20 || NET35 + if ((type & CartType.Ym2151At460461) != 0) +#else + if (type.HasFlag(CartType.Ym2151At460461)) +#endif + types.Add("ym2151 at $460/$461"); + +#if NET20 || NET35 + if ((type & CartType.Souper) != 0) +#else + if (type.HasFlag(CartType.Souper)) +#endif + types.Add("souper"); + +#if NET20 || NET35 + if ((type & CartType.Banksets) != 0) +#else + if (type.HasFlag(CartType.Banksets)) +#endif + types.Add("banksets"); + +#if NET20 || NET35 + if ((type & CartType.HaltBankedRam) != 0) +#else + if (type.HasFlag(CartType.HaltBankedRam)) +#endif + types.Add("halt banked ram"); + +#if NET20 || NET35 + if ((type & CartType.PokeyAt800) != 0) +#else + if (type.HasFlag(CartType.PokeyAt800)) +#endif + types.Add("pokey@800"); + + // If no flags are set + if (types.Count == 0) + types.Add("N/A"); + + return string.Join(", ", [.. types]); + } + + /// + /// Convert a value to string + /// + public static string FromControllerType(this ControllerType type) + { + return type switch + { + ControllerType.None => "none", + ControllerType.Joystick => "7800 joystick", + ControllerType.Lightgun => "lightgun", + ControllerType.Paddle => "paddle", + ControllerType.Trakball => "trakball", + ControllerType.VcsJoystick => "2600 joystick", + ControllerType.VcsDriving => "2600 driving", + ControllerType.VcsKeypad => "2600 keypad", + ControllerType.STMouse => "ST mouse", + ControllerType.AmigaMouse => "Amiga mouse", + ControllerType.AtariVoxSaveKey => "AtariVox/SaveKey", + ControllerType.SNES2Atari => "SNES2Atari", + ControllerType.Mega7800 => "Mega7800", + _ => $"Unknown {(byte)type}", + }; + } + + /// + /// Convert a value to string + /// + public static string FromInterrupt(this Interrupt interrupt) + { + return interrupt switch + { + Interrupt.None => "None", + Interrupt.Pokey1 => "POKEY 1 (@450 | @800 | @4000)", + Interrupt.Pokey2 => "POKEY 2 (@440)", + Interrupt.YM2151 => "YM2151", + _ => $"Unknown {(byte)interrupt}", + }; + } + + /// + /// Convert a value to string + /// + public static string FromMapper(this Mapper mapper) + { + return mapper switch + { + Mapper.Linear => "Linear", + Mapper.SuperGame => "SuperGame", + Mapper.Activision => "Activision", + Mapper.Absolute => "Absolute", + Mapper.Souper => "Souper", + _ => $"Unknown {(byte)mapper}", + }; + } + + /// + /// Convert a value to string + /// + public static string FromMapperOptions(this MapperOptions options) + { + string[] romOptions = new string[2]; + + byte option4000 = (byte)((byte)options & 0x0F); + romOptions[0] = option4000 switch + { + 0 => "Option at @4000 - none", + 1 => "Option at @4000 - 16K RAM", + 2 => "Option at @4000 - 8K EXRAM/A8", + 3 => "Option at @4000 - 32K EXRAM/M2", + 4 => "Option at @4000 - EXROM", + 5 => "Option at @4000 - EXFIX", + 6 => "Option at @4000 - 32k EXRAM/X2", + _ => $"Unknown {option4000}", + }; + + byte romType = (byte)(((byte)options >> 7) & 0x01); + romOptions[1] = romType switch + { + 0 => "Standard ROM", + 1 => "Bankset ROM", + _ => $"Unknown {romType}", + }; + + return string.Join(", ", [.. romOptions]); + } + + /// + /// Convert a value to string + /// + public static string FromSaveDevice(this SaveDevice device) + { + return device switch + { + SaveDevice.None => "None", + SaveDevice.HSC => "HSC", + SaveDevice.SaveKeyAtariVox => "SaveKey/AtariVox", + _ => $"Unknown {(byte)device}", + }; + } + + /// + /// Convert a value to string + /// + public static string FromSlotPassthroughDevice(this SlotPassthroughDevice device) + { + return device switch + { + SlotPassthroughDevice.None => "None", + SlotPassthroughDevice.XM => "XM", + _ => $"Unknown {(byte)device}", + }; + } + + /// + /// Convert a value to string + /// + public static string FromTVType(this TVType type) + { + string[] types = new string[3]; + + byte standard = (byte)((byte)type & 0x01); + types[0] = standard switch + { + 0 => "NTSC", + 1 => "PAL", + _ => $"Unknown {standard}", + }; + + byte connection = (byte)(((byte)type >> 1) & 0x01); + types[1] = connection switch + { + 0 => "Component", + 1 => "Composite", + _ => $"Unknown {standard}", + }; + + byte region = (byte)(((byte)type >> 2) & 0x01); + types[2] = region switch + { + 0 => "Single-region", + 1 => "Multi-region", + _ => $"Unknown {standard}", + }; + + return string.Join(", ", [.. types]); + } + } +} diff --git a/SabreTools.Serialization/Extensions/NESCart.cs b/SabreTools.Serialization/Extensions/NESCart.cs index b53e1e85..1345cc4a 100644 --- a/SabreTools.Serialization/Extensions/NESCart.cs +++ b/SabreTools.Serialization/Extensions/NESCart.cs @@ -5,7 +5,7 @@ namespace SabreTools.Data.Extensions public static class NESCart { /// - /// Convert a value to string + /// Convert a value to string /// public static string FromConsoleType(this ConsoleType type) { diff --git a/SabreTools.Serialization/Models/Atari7800/Cart.cs b/SabreTools.Serialization/Models/Atari7800/Cart.cs new file mode 100644 index 00000000..550367fc --- /dev/null +++ b/SabreTools.Serialization/Models/Atari7800/Cart.cs @@ -0,0 +1,20 @@ +namespace SabreTools.Data.Models.Atari7800 +{ + /// + /// Atari 7800 headered cart + /// + /// + public class Cart + { + /// + /// A78 header + /// + /// If omitted, format is technically BIN + public Header? Header { get; set; } + + /// + /// Cartridge data + /// + public byte[] Data { get; set; } = []; + } +} diff --git a/SabreTools.Serialization/Models/Atari7800/Constants.cs b/SabreTools.Serialization/Models/Atari7800/Constants.cs new file mode 100644 index 00000000..68f5c08e --- /dev/null +++ b/SabreTools.Serialization/Models/Atari7800/Constants.cs @@ -0,0 +1,49 @@ +namespace SabreTools.Data.Models.Atari7800 +{ + public static class Constants + { + /// + /// A78 magic string (null-padded) + /// + public static readonly byte[] MagicBytesWithNull = + [ + 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + + /// + /// A78 magic string (null-padded) + /// + public const string MagicStringWithNull = "ATARI7800\0\0\0\0\0\0\0"; + + /// + /// A78 magic string (space-padded) + /// + public static readonly byte[] MagicBytesWithSpace = + [ + 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, + 0x30, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + ]; + + /// + /// A78 magic string (space-padded) + /// + public const string MagicStringWithSpace = "ATARI7800 "; + + /// + /// A78 header end magic text + /// + public static readonly byte[] HeaderEndMagicBytes = + [ + 0x41, 0x43, 0x54, 0x55, 0x41, 0x4C, 0x20, 0x43, + 0x41, 0x52, 0x54, 0x20, 0x44, 0x41, 0x54, 0x41, + 0x20, 0x53, 0x54, 0x41, 0x52, 0x54, 0x53, 0x20, + 0x48, 0x45, 0x52, 0x45, + ]; + + /// + /// A78 header end magic text + /// + public const string HeaderEndMagicString = "ACTUAL CART DATA STARTS HERE"; + } +} diff --git a/SabreTools.Serialization/Models/Atari7800/Enums.cs b/SabreTools.Serialization/Models/Atari7800/Enums.cs new file mode 100644 index 00000000..818764b0 --- /dev/null +++ b/SabreTools.Serialization/Models/Atari7800/Enums.cs @@ -0,0 +1,380 @@ +using System; + +namespace SabreTools.Data.Models.Atari7800 +{ + /// + /// Audio device (v4+ only) + /// + /// + [Flags] + public enum AudioDevice : ushort + { + #region POKEY location + + /// + /// POKEY - none + /// + PokeyNone = 0b00000000, + + /// + /// POKEY - @440 + /// + Pokey440 = 0b00000001, + + /// + /// POKEY - @450 + /// + Pokey450 = 0b00000010, + + /// + /// POKEY - @450+@440 + /// + Pokey450Plus440 = 0b00000011, + + /// + /// POKEY - @800 + /// + Pokey800 = 0b00000100, + + /// + /// POKEY - @4000 + /// + Pokey4000 = 0b00000101, + + #endregion + + /// + /// YM2151 @460 + /// + YM2151460 = 0b00001000, + + /// + /// COVOX @430 + /// + COVOX430 = 0b00010000, + + /// + /// ADPCM Audio Stream @420 + /// + ADPCMAudioStream420 = 0b00100000, + } + + /// + /// Cart type specifications + /// + /// + [Flags] + public enum CartType : ushort + { + /// + /// pokey at $4000 + /// + PokeyAt4000 = 0b00000000_00000001, + + /// + /// supergame bank switched + /// + SupergameBankSwitched = 0b00000000_00000010, + + /// + /// supergame ram at $4000 + /// + SupergameRamAt4000 = 0b00000000_00000100, + + /// + /// rom at $4000 + /// + RomAt4000 = 0b00000000_00001000, + + /// + /// bank 6 at $4000 + /// + Bank6At4000 = 0b00000000_00010000, + + /// + /// banked ram + /// + BankedRam = 0b00000000_00100000, + + /// + /// pokey at $450 + /// + PokeyAt450 = 0b00000000_01000000, + + /// + /// mirror ram at $4000 + /// + MirrorRamAt4000 = 0b00000000_10000000, + + /// + /// activision banking + /// + ActivisionBanking = 0b00000001_00000000, + + /// + /// absolute banking + /// + AbsoluteBanking = 0b00000010_00000000, + + /// + /// pokey at $440 + /// + PokeyAt440 = 0b00000100_00000000, + + /// + /// ym2151 at $460/$461 + /// + Ym2151At460461 = 0b00001000_00000000, + + /// + /// souper + /// + Souper = 0b00010000_00000000, + + /// + /// banksets + /// + Banksets = 0b00100000_00000000, + + /// + /// halt banked ram + /// + HaltBankedRam = 0b01000000_00000000, + + /// + /// pokey@800 + /// + PokeyAt800 = 0b10000000_00000000, + } + + /// + /// Controller type + /// + /// + public enum ControllerType : byte + { + /// + /// none + /// + None = 0, + + /// + /// 7800 joystick + /// + Joystick = 1, + + /// + /// lightgun + /// + Lightgun = 2, + + /// + /// paddle + /// + Paddle = 3, + + /// + /// trakball + /// + Trakball = 4, + + /// + /// 2600 joystick + /// + VcsJoystick = 5, + + /// + /// 2600 driving + /// + VcsDriving = 6, + + /// + /// 2600 keypad + /// + VcsKeypad = 7, + + /// + /// ST mouse + /// + STMouse = 8, + + /// + /// Amiga mouse + /// + AmigaMouse = 9, + + /// + /// AtariVox/SaveKey + /// + AtariVoxSaveKey = 10, + + /// + /// SNES2Atari + /// + SNES2Atari = 11, + + /// + /// Mega7800 + /// + Mega7800 = 12, + } + + /// + /// Interrupt (v4+ only) + /// + /// + [Flags] + public enum Interrupt : ushort + { + None = 0b00000000, + + /// + /// POKEY 1 (@450 | @800 | @4000) + /// + Pokey1 = 0b00000001, + + /// + /// POKEY 2 (@440) + /// + Pokey2 = 0b00000010, + + /// + /// YM2151 + /// + YM2151 = 0b00000100, + } + + /// + /// Mapper (v4+ only) + /// + /// + public enum Mapper : byte + { + Linear = 0, + SuperGame = 1, + Activision = 2, + Absolute = 3, + Souper = 4, + } + + /// + /// Mapper options/details (v4+ only) + /// + /// + [Flags] + public enum MapperOptions : byte + { + #region Linear + + /// + /// Option at @4000 - none + /// + LinearNone = 0b00000000, + + /// + /// Option at @4000 - 16K RAM + /// + Linear16KRAM = 0b00000001, + + /// + /// Option at @4000 - 8K EXRAM/A8 + /// + Linear8KEXRAMA8 = 0b00000010, + + /// + /// Option at @4000 - 32K EXRAM/M2 + /// + Linear32KEXRAMM2 = 0b00000011, + + #endregion + + #region SuperGame + + /// + /// Option at @4000 - none + /// + SuperGameNone = 0b00000000, + + /// + /// Option at @4000 - 16K RAM + /// + SuperGame16KRAM = 0b00000001, + + /// + /// Option at @4000 - 8K EXRAM/A8 + /// + SuperGame8KEXRAMA8 = 0b00000010, + + /// + /// Option at @4000 - 32K EXRAM/M2 + /// + SuperGame32KEXRAMM2 = 0b00000011, + + /// + /// Option at @4000 - EXROM + /// + SuperGameEXROM = 0b00000100, + + /// + /// Option at @4000 - EXFIX + /// + SuperGameEXFIX = 0b00000101, + + /// + /// Option at @4000 - 32k EXRAM/X2 + /// + SuperGame32KEXRAMX2 = 0b00000110, + + #endregion + + /// + /// Bankset ROM + /// + BanksetRom = 0b10000000, + } + + /// + /// Save device + /// + /// + public enum SaveDevice : byte + { + None = 0b00000000, + + /// + /// HSC + /// + HSC = 0b00000001, + + /// + /// SaveKey/AtariVox + /// + SaveKeyAtariVox = 0b00000010, + } + + /// + /// Slot passthrough device + /// + public enum SlotPassthroughDevice : byte + { + None = 0b00000000, + XM = 0b00000001, + } + + /// + /// TV type + /// + /// + [Flags] + public enum TVType : byte + { + NTSC = 0b00000000, + PAL = 0b00000001, + + Component = 0b00000000, + Composite = 0b00000010, + + SingleRegion = 0b00000000, + MultiRegion = 0b00000100, + } +} diff --git a/SabreTools.Serialization/Models/Atari7800/Header.cs b/SabreTools.Serialization/Models/Atari7800/Header.cs new file mode 100644 index 00000000..a99cabea --- /dev/null +++ b/SabreTools.Serialization/Models/Atari7800/Header.cs @@ -0,0 +1,115 @@ +namespace SabreTools.Data.Models.Atari7800 +{ + /// + /// Atari 7800 emulator header + /// + /// + public class Header + { + /// + /// Header version + /// + /// + /// Current header version is 4. + /// If no V4 features are used, this should be set to 3. + /// + public byte HeaderVersion { get; set; } + + /// + /// "ATARI7800" with 7 null bytes following + /// + public byte[] MagicText { get; set; } = new byte[16]; + + /// + /// Cart title, encoding not specified + /// + public byte[] CartTitle { get; set; } = new byte[32]; + + /// + /// ROM size without header data + /// + /// + /// Endianness possibly varies by version? + /// Version 1 - Big-endian + /// Version 2 - ????? + /// Version 3 - ????? + /// Version 4 - ????? + /// + public uint RomSizeWithoutHeader { get; set; } + + /// + /// Cart type flags + /// + public CartType CartType { get; set; } + + /// + /// Controller 1 type + /// + public ControllerType Controller1Type { get; set; } + + /// + /// Controller 2 type + /// + public ControllerType Controller2Type { get; set; } + + /// + /// TV type + /// + public TVType TVType { get; set; } + + /// + /// Save device + /// + public SaveDevice SaveDevice { get; set; } + + /// + /// Reserved + /// + public byte[] Reserved { get; set; } = new byte[4]; + + /// + /// Slot passthrough device + /// + public SlotPassthroughDevice SlotPassthroughDevice { get; set; } + + #region Version 4+ + + /// + /// Mapper + /// + /// v4+ only + public Mapper Mapper { get; set; } + + /// + /// Mapper options + /// + /// v4+ only + public MapperOptions MapperOptions { get; set; } + + /// + /// Audio device(s) + /// + public AudioDevice AudioDevice { get; set; } + + /// + /// Interrupt + /// + public Interrupt Interrupt { get; set; } + + #endregion + + /// + /// Padding from end of header data to end magic text + /// + /// + /// 36 bytes in versions lower than 4. + /// 30 bytes in versions 4 and above. + /// + public byte[] Padding { get; set; } = []; + + /// + /// "ACTUAL CART DATA STARTS HERE" + /// + public byte[] EndMagicText { get; set; } = new byte[28]; + } +} diff --git a/SabreTools.Serialization/Models/README.MD b/SabreTools.Serialization/Models/README.MD index 6756a39a..382286c6 100644 --- a/SabreTools.Serialization/Models/README.MD +++ b/SabreTools.Serialization/Models/README.MD @@ -11,6 +11,7 @@ Not all of this information was able to be gathered directly from the files in q | [3DBrew](https://www.3dbrew.org/wiki/Main_Page) | N3DS | | [Aaru Data Preservation Suite](https://github.com/aaru-dps) | PIC | | [Advanced Access Content System Licensing Administrator (AACS LA)](https://aacsla.com/) | AACS | +| [Atari 7800 Development Wiki](https://7800.8bitdev.org/index.php/Main_Page) | Atari7800Cart | | [BYTE*](https://web.archive.org/web/20240703222951/https://bytepointer.com/index.htm) | NewExecutable | | [cabextract/libmspack](https://www.cabextract.org.uk/) | LZ | | [DBox](https://dbox.tools/) | Xbox | diff --git a/SabreTools.Serialization/Readers/Atari7800Cart.cs b/SabreTools.Serialization/Readers/Atari7800Cart.cs new file mode 100644 index 00000000..2720cf69 --- /dev/null +++ b/SabreTools.Serialization/Readers/Atari7800Cart.cs @@ -0,0 +1,96 @@ +using System.IO; +using SabreTools.Data.Models.Atari7800; +using SabreTools.IO.Extensions; +using static SabreTools.Data.Models.Atari7800.Constants; + +namespace SabreTools.Serialization.Readers +{ + public class Atari7800Cart : 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.MagicText.EqualsExactly(MagicBytesWithNull) + && !header.MagicText.EqualsExactly(MagicBytesWithSpace)) + { + return null; + } + else if (!header.EndMagicText.EqualsExactly(HeaderEndMagicBytes)) + { + return null; + } + + // Set the header + cart.Header = header; + + #endregion + + // Read the cart data, if necessary + if (cart.Header.RomSizeWithoutHeader > 0) + cart.Data = data.ReadBytes((int)cart.Header.RomSizeWithoutHeader); + + 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.HeaderVersion = data.ReadByteValue(); + obj.MagicText = data.ReadBytes(16); + obj.CartTitle = data.ReadBytes(32); + obj.RomSizeWithoutHeader = data.ReadUInt32BigEndian(); + obj.CartType = (CartType)data.ReadUInt16LittleEndian(); + obj.Controller1Type = (ControllerType)data.ReadByteValue(); + obj.Controller2Type = (ControllerType)data.ReadByteValue(); + obj.TVType = (TVType)data.ReadByteValue(); + obj.SaveDevice = (SaveDevice)data.ReadByteValue(); + obj.Reserved = data.ReadBytes(4); + obj.SlotPassthroughDevice = (SlotPassthroughDevice)data.ReadByteValue(); + + if (obj.HeaderVersion >= 4) + { + obj.Mapper = (Mapper)data.ReadByteValue(); + obj.MapperOptions = (MapperOptions)data.ReadByteValue(); + obj.AudioDevice = (AudioDevice)data.ReadUInt16LittleEndian(); + obj.Interrupt = (Interrupt)data.ReadUInt16LittleEndian(); + obj.Padding = data.ReadBytes(30); + } + else + { + obj.Padding = data.ReadBytes(36); + } + + obj.EndMagicText = data.ReadBytes(28); + + return obj; + } + } +} diff --git a/SabreTools.Serialization/Readers/NESCart.cs b/SabreTools.Serialization/Readers/NESCart.cs index ab1b36da..c3ce7a4d 100644 --- a/SabreTools.Serialization/Readers/NESCart.cs +++ b/SabreTools.Serialization/Readers/NESCart.cs @@ -26,7 +26,7 @@ namespace SabreTools.Serialization.Readers // Try to parse the header var header = ParseHeader(data); - if (header == null) + if (header is null) return null; // Set the header diff --git a/SabreTools.Serialization/Readers/SkuSis.cs b/SabreTools.Serialization/Readers/SkuSis.cs index 273426cc..70650593 100644 --- a/SabreTools.Serialization/Readers/SkuSis.cs +++ b/SabreTools.Serialization/Readers/SkuSis.cs @@ -20,7 +20,7 @@ namespace SabreTools.Serialization.Readers public override Data.Models.VDF.SkuSis? Deserialize(Stream? data) { // If the data is invalid - if (data == null || !data.CanRead) + if (data is null || !data.CanRead) return null; try @@ -39,12 +39,12 @@ namespace SabreTools.Serialization.Readers data.SeekIfPossible(initialOffset, SeekOrigin.Begin); var jsonBytes = ParseSkuSis(data); - if (jsonBytes == null) + if (jsonBytes is null) return null; var deserializer = new SkuSisJson(); var skuSisJson = deserializer.Deserialize(jsonBytes, 0); - if (skuSisJson == null) + if (skuSisJson is null) return null; return skuSisJson; @@ -106,7 +106,7 @@ namespace SabreTools.Serialization.Readers while (!reader.EndOfStream) { string? line = reader.ReadLine(); - if (line == null) + if (line is null) continue; // Curly braces are always on their own lines diff --git a/SabreTools.Serialization/WrapperFactory.cs b/SabreTools.Serialization/WrapperFactory.cs index 6d6e31df..dbe082ab 100644 --- a/SabreTools.Serialization/WrapperFactory.cs +++ b/SabreTools.Serialization/WrapperFactory.cs @@ -15,6 +15,7 @@ namespace SabreTools.Serialization return fileType switch { WrapperType.AACSMediaKeyBlock => AACSMediaKeyBlock.Create(data), + WrapperType.Atari7800Cart => Atari7800Cart.Create(data), WrapperType.BDPlusSVM => BDPlusSVM.Create(data), WrapperType.BFPK => BFPK.Create(data), WrapperType.BSP => BSP.Create(data), @@ -168,6 +169,30 @@ namespace SabreTools.Serialization #endregion + // TODO: Figure out how to use constants here + #region Atari7800Cart + + // Version 1 header + if (magic.StartsWith([0x01, 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, 0x30])) + return WrapperType.Atari7800Cart; + + // Version 2 header + if (magic.StartsWith([0x02, 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, 0x30])) + return WrapperType.Atari7800Cart; + + // Version 3 header + if (magic.StartsWith([0x03, 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, 0x30])) + return WrapperType.Atari7800Cart; + + // Version 4 header + if (magic.StartsWith([0x04, 0x41, 0x54, 0x41, 0x52, 0x49, 0x37, 0x38, 0x30, 0x30])) + return WrapperType.Atari7800Cart; + + if (extension.Equals("a78", StringComparison.OrdinalIgnoreCase)) + return WrapperType.Atari7800Cart; + + #endregion + #region BDPlusSVM if (magic.StartsWith(Data.Models.BDPlus.Constants.SignatureBytes)) diff --git a/SabreTools.Serialization/Wrappers/Atari7800Cart.Extraction.cs b/SabreTools.Serialization/Wrappers/Atari7800Cart.Extraction.cs new file mode 100644 index 00000000..d9326861 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/Atari7800Cart.Extraction.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using SabreTools.Data.Models.Atari7800; +using SabreTools.IO.Extensions; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class Atari7800Cart : 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); + + // Byte 0 + fs.Write(Header.HeaderVersion); + fs.Flush(); + + // Bytes 1-16 + fs.Write(Header.MagicText); + fs.Flush(); + + // Bytes 17-48 + fs.Write(Header.CartTitle); + fs.Flush(); + + // Bytes 49-52 + fs.Write(Header.RomSizeWithoutHeader); + fs.Flush(); + + // Bytes 53-54 + fs.Write((ushort)Header.CartType); + fs.Flush(); + + // Bytes 55-56 + fs.Write((byte)Header.Controller1Type); + fs.Write((byte)Header.Controller2Type); + fs.Flush(); + + // Byte 57 + fs.Write((byte)Header.TVType); + fs.Flush(); + + // Byte 58 + fs.Write((byte)Header.SaveDevice); + fs.Flush(); + + // Bytes 59-62 + fs.Write(Header.Reserved); + fs.Flush(); + + // Byte 63 + fs.Write((byte)Header.SlotPassthroughDevice); + fs.Flush(); + + if (HeaderVersion >= 4) + { + // Byte 64 + fs.Write((byte)Header.Mapper); + fs.Flush(); + + // Byte 65 + fs.Write((byte)Header.MapperOptions); + fs.Flush(); + + // Byte 66-67 + fs.Write((ushort)Header.AudioDevice); + fs.Flush(); + + // Byte 68-69 + fs.Write((ushort)Header.Interrupt); + fs.Flush(); + + // Bytes 70-99 + fs.Write(Header.Padding); + fs.Flush(); + } + else + { + // Bytes 64-99 + fs.Write(Header.Padding); + fs.Flush(); + } + + // Bytes 100-127 + fs.Write(Header.EndMagicText); + 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/Atari7800Cart.Printing.cs b/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs new file mode 100644 index 00000000..da3361c4 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/Atari7800Cart.Printing.cs @@ -0,0 +1,91 @@ +using System.Text; +using SabreTools.Data.Extensions; +using SabreTools.Data.Models.Atari7800; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class Atari7800Cart : IPrintable + { +#if NETCOREAPP + /// + public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions); +#endif + + /// + public void PrintInformation(StringBuilder builder) + { + builder.AppendLine("Atari 7800 Cart Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + Print(builder, Model.Header); + + //builder.AppendLine(Model.Trainer, "Trainer 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.HeaderVersion, " Header version"); + builder.AppendLine(header.MagicText, " Magic text"); + builder.AppendLine(Encoding.ASCII.GetString(header.MagicText).TrimEnd('\0'), " Magic text (ASCII)"); + builder.AppendLine(header.CartTitle, " Cart title"); + builder.AppendLine(Encoding.ASCII.GetString(header.CartTitle).TrimEnd('\0'), " Cart title (ASCII)"); + builder.AppendLine(header.RomSizeWithoutHeader, " ROM size without header"); + + string cartType = header.CartType.FromCartType(); + builder.AppendLine(cartType, " Cart type flags"); + + string controller1Type = header.Controller1Type.FromControllerType(); + builder.AppendLine(controller1Type, " Controller 1 type"); + + string controller2Type = header.Controller2Type.FromControllerType(); + builder.AppendLine(controller2Type, " Controller 2 type"); + + string tvType = header.TVType.FromTVType(); + builder.AppendLine(tvType, " TV type"); + + string saveDevice = header.SaveDevice.FromSaveDevice(); + builder.AppendLine(saveDevice, " Save device"); + + builder.AppendLine(header.Reserved, " Reserved"); + + string slotPassthroughDevice = header.SlotPassthroughDevice.FromSlotPassthroughDevice(); + builder.AppendLine(slotPassthroughDevice, " Slot passthrough device"); + + if (header.HeaderVersion >= 4) + { + string mapper = header.Mapper.FromMapper(); + builder.AppendLine(mapper, " Mapper"); + + string mapperOptions = header.MapperOptions.FromMapperOptions(); + builder.AppendLine(mapperOptions, " Mapper options"); + + string audioDevice = header.AudioDevice.FromAudioDevice(); + builder.AppendLine(audioDevice, " Audio device"); + + string interrupt = header.Interrupt.FromInterrupt(); + builder.AppendLine(interrupt, " Interrupt"); + + builder.AppendLine(header.Padding, " Padding"); + } + else + { + builder.AppendLine(header.Padding, " Padding"); + } + + builder.AppendLine(header.EndMagicText, " End magic text"); + builder.AppendLine(Encoding.ASCII.GetString(header.EndMagicText).TrimEnd('\0'), " End magic text (ASCII)"); + builder.AppendLine(); + } + } +} diff --git a/SabreTools.Serialization/Wrappers/Atari7800Cart.cs b/SabreTools.Serialization/Wrappers/Atari7800Cart.cs new file mode 100644 index 00000000..0d28253d --- /dev/null +++ b/SabreTools.Serialization/Wrappers/Atari7800Cart.cs @@ -0,0 +1,147 @@ +using System.IO; +using System.Text; +using SabreTools.Data.Models.Atari7800; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class Atari7800Cart : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "Atari 7800 Cart Image"; + + #endregion + + #region Extension Properties + + /// + public Header? Header => Model.Header; + + /// + public AudioDevice AudioDevice => Header?.AudioDevice ?? 0; + + /// + public string? CartTitle + { + get + { + // Missing header + if (Header is null) + return null; + + return Encoding.ASCII.GetString(Header.CartTitle); + } + } + + /// + public CartType CartType => Header?.CartType ?? 0; + + /// + public ControllerType Controller1Type => Header?.Controller1Type ?? 0; + + /// + public ControllerType Controller2Type => Header?.Controller2Type ?? 0; + + /// + public byte HeaderVersion => Header?.HeaderVersion ?? 0; + + /// + public Interrupt Interrupt => Header?.Interrupt ?? 0; + + /// + public Mapper Mapper => Header?.Mapper ?? 0; + + /// + public MapperOptions MapperOptions => Header?.MapperOptions ?? 0; + + /// + public uint RomSizeWithoutHeader => Header?.RomSizeWithoutHeader ?? 0; + + /// + public SaveDevice SaveDevice => Header?.SaveDevice ?? 0; + + /// + public SlotPassthroughDevice SlotPassthroughDevice => Header?.SlotPassthroughDevice ?? 0; + + /// + public TVType TVType => Header?.TVType ?? 0; + + #endregion + + #region Constructors + + /// + public Atari7800Cart(Cart model, byte[] data) : base(model, data) { } + + /// + public Atari7800Cart(Cart model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public Atari7800Cart(Cart model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public Atari7800Cart(Cart model, Stream data) : base(model, data) { } + + /// + public Atari7800Cart(Cart model, Stream data, long offset) : base(model, data, offset) { } + + /// + public Atari7800Cart(Cart model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an Atari 7800 cart image from a byte array and offset + /// + /// Byte array representing the archive + /// Offset within the array to parse + /// An Atari 7800 cart image wrapper on success, null on failure + public static Atari7800Cart? 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 7800 cart image from a Stream + /// + /// Stream representing the archive + /// An Atari 7800 cart image wrapper on success, null on failure + public static Atari7800Cart? 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.Atari7800Cart().Deserialize(data); + if (model is null) + return null; + + return new Atari7800Cart(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Serialization/Wrappers/SkuSis.cs b/SabreTools.Serialization/Wrappers/SkuSis.cs index b030ee1f..a1bde7fc 100644 --- a/SabreTools.Serialization/Wrappers/SkuSis.cs +++ b/SabreTools.Serialization/Wrappers/SkuSis.cs @@ -95,7 +95,7 @@ namespace SabreTools.Serialization.Wrappers public static SkuSis? Create(byte[]? data, int offset) { // If the data is invalid - if (data == null || data.Length == 0) + if (data is null || data.Length == 0) return null; // If the offset is out of bounds @@ -115,7 +115,7 @@ namespace SabreTools.Serialization.Wrappers public static SkuSis? Create(Stream? data) { // If the data is invalid - if (data == null || !data.CanRead) + if (data is null || !data.CanRead) return null; try @@ -124,7 +124,7 @@ namespace SabreTools.Serialization.Wrappers long currentOffset = data.Position; var model = new Readers.SkuSis().Deserialize(data); - if (model == null) + if (model is null) return null; return new SkuSis(model, data, currentOffset); diff --git a/SabreTools.Serialization/Wrappers/WrapperType.cs b/SabreTools.Serialization/Wrappers/WrapperType.cs index 97c94c36..e9822d1e 100644 --- a/SabreTools.Serialization/Wrappers/WrapperType.cs +++ b/SabreTools.Serialization/Wrappers/WrapperType.cs @@ -15,6 +15,11 @@ namespace SabreTools.Serialization.Wrappers /// AACSMediaKeyBlock, + /// + /// Atari7800 cart image + /// + Atari7800Cart, + /// /// BD+ SVM ///