From 70fe830241b84edb2d5cf1cdf7910aeca92a50dc Mon Sep 17 00:00:00 2001 From: Matt Nadareski Date: Tue, 10 Mar 2026 17:27:58 -0400 Subject: [PATCH] Add fwNES FDS file support --- ExtractionTool/Features/MainFeature.cs | 5 + README.MD | 1 + .../Readers/FDSTests.cs | 73 +++++++++++++ .../Wrappers/FDSTests.cs | 61 +++++++++++ .../Models/NES/Constants.cs | 10 ++ SabreTools.Serialization/Models/NES/FDS.cs | 19 ++++ .../Models/NES/FDSHeader.cs | 24 +++++ SabreTools.Serialization/Models/README.MD | 2 +- SabreTools.Serialization/Readers/FDS.cs | 68 ++++++++++++ SabreTools.Serialization/Readers/NESCart.cs | 4 +- SabreTools.Serialization/WrapperFactory.cs | 13 +++ .../Wrappers/FDS.Extraction.cs | 81 ++++++++++++++ .../Wrappers/FDS.Printing.cs | 44 ++++++++ SabreTools.Serialization/Wrappers/FDS.cs | 100 ++++++++++++++++++ .../Wrappers/WrapperType.cs | 5 + 15 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 SabreTools.Serialization.Test/Readers/FDSTests.cs create mode 100644 SabreTools.Serialization.Test/Wrappers/FDSTests.cs create mode 100644 SabreTools.Serialization/Models/NES/FDS.cs create mode 100644 SabreTools.Serialization/Models/NES/FDSHeader.cs create mode 100644 SabreTools.Serialization/Readers/FDS.cs create mode 100644 SabreTools.Serialization/Wrappers/FDS.Extraction.cs create mode 100644 SabreTools.Serialization/Wrappers/FDS.Printing.cs create mode 100644 SabreTools.Serialization/Wrappers/FDS.cs diff --git a/ExtractionTool/Features/MainFeature.cs b/ExtractionTool/Features/MainFeature.cs index eef35438..3c4350af 100644 --- a/ExtractionTool/Features/MainFeature.cs +++ b/ExtractionTool/Features/MainFeature.cs @@ -185,6 +185,11 @@ namespace ExtractionTool.Features cfb.Extract(OutputPath, Debug); break; + // fwNES FDS file + case FDS fds: + fds.Extract(OutputPath, Debug); + break; + // GCF case GCF gcf: gcf.Extract(OutputPath, Debug); diff --git a/README.MD b/README.MD index 0daf6290..2c6c0500 100644 --- a/README.MD +++ b/README.MD @@ -58,6 +58,7 @@ Options: | BFPK custom archive format | | | bzip2 archive | .NET Framework 4.6.2 and greater | | Compound File Binary (CFB) | Only CFB common pieces extractable | +| fwNES FDS file | Header and disk data | | gzip archive | | | Half-Life Game Cache File (GCF) | | | Half-Life Level (BSP) | | diff --git a/SabreTools.Serialization.Test/Readers/FDSTests.cs b/SabreTools.Serialization.Test/Readers/FDSTests.cs new file mode 100644 index 00000000..0250142b --- /dev/null +++ b/SabreTools.Serialization.Test/Readers/FDSTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Readers; +using Xunit; + +namespace SabreTools.Serialization.Test.Readers +{ + public class FDSTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new FDS(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new FDS(); + + 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 FDS(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new FDS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new FDS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new FDS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization.Test/Wrappers/FDSTests.cs b/SabreTools.Serialization.Test/Wrappers/FDSTests.cs new file mode 100644 index 00000000..4f9127b5 --- /dev/null +++ b/SabreTools.Serialization.Test/Wrappers/FDSTests.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Wrappers; +using Xunit; + +namespace SabreTools.Serialization.Test.Wrappers +{ + public class FDSTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = FDS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = FDS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = FDS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = FDS.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = FDS.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = FDS.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization/Models/NES/Constants.cs b/SabreTools.Serialization/Models/NES/Constants.cs index 8cb84071..f40684e3 100644 --- a/SabreTools.Serialization/Models/NES/Constants.cs +++ b/SabreTools.Serialization/Models/NES/Constants.cs @@ -11,5 +11,15 @@ namespace SabreTools.Data.Models.NES /// NES /// public static readonly string CartSignatureString = "NES" + (char)0x1A; + + /// + /// FDS + /// + public static readonly byte[] FDSSignatureBytes = [0x46, 0x44, 0x53, 0x1A]; + + /// + /// FDS + /// + public static readonly string FDSSignatureString = "FDS" + (char)0x1A; } } diff --git a/SabreTools.Serialization/Models/NES/FDS.cs b/SabreTools.Serialization/Models/NES/FDS.cs new file mode 100644 index 00000000..0981c5e4 --- /dev/null +++ b/SabreTools.Serialization/Models/NES/FDS.cs @@ -0,0 +1,19 @@ +namespace SabreTools.Data.Models.NES +{ + /// + /// fwNES FDS file + /// + /// + public class FDS + { + /// + /// FDS header + /// + public FDSHeader? Header { get; set; } + + /// + /// Disk data (65500 * x bytes) + /// + public byte[] Data { get; set; } = []; + } +} diff --git a/SabreTools.Serialization/Models/NES/FDSHeader.cs b/SabreTools.Serialization/Models/NES/FDSHeader.cs new file mode 100644 index 00000000..62304b27 --- /dev/null +++ b/SabreTools.Serialization/Models/NES/FDSHeader.cs @@ -0,0 +1,24 @@ +namespace SabreTools.Data.Models.NES +{ + /// + /// fwNES FDS header + /// + /// + public class FDSHeader + { + /// + /// Constant $46 $44 $53 $1A ("FDS" followed by MS-DOS end-of-file) + /// + public byte[] IdentificationString { get; set; } = new byte[4]; + + /// + /// Number of disk sides + /// + public byte DiskSides { get; set; } + + /// + /// Zero-filled padding + /// + public byte[] Padding { get; set; } = new byte[11]; + } +} diff --git a/SabreTools.Serialization/Models/README.MD b/SabreTools.Serialization/Models/README.MD index 7c2e6016..0eaa482f 100644 --- a/SabreTools.Serialization/Models/README.MD +++ b/SabreTools.Serialization/Models/README.MD @@ -38,7 +38,7 @@ Not all of this information was able to be gathered directly from the files in q | [Matthew Russotto](http://www.russotto.net/quantumcomp.html) | Compression/Quantum | | [Microsoft Learn](https://learn.microsoft.com/en-us/) | BMP, CFB, Compression/LZX, Compression/MSZIP, MicrosoftCabinet, OLE, PortableExecutable, SecuROM, WiseInstaller | | [msitools](https://github.com/GNOME/msitools/) | CFB | -| [Nesdev Wiki](https://www.nesdev.org/wiki/Nesdev_Wiki) | NESCart | +| [Nesdev Wiki](https://www.nesdev.org/wiki/Nesdev_Wiki) | FDS, NESCart | | [OSDev.org](https://wiki.osdev.org/Expanded_Main_Page) | MSDOS, NewExecutable | | [PInvoke.net](http://www.pinvoke.net/index.aspx) | MSDOS | | [PKWARE(?)](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) | PKZIP | diff --git a/SabreTools.Serialization/Readers/FDS.cs b/SabreTools.Serialization/Readers/FDS.cs new file mode 100644 index 00000000..f5e73870 --- /dev/null +++ b/SabreTools.Serialization/Readers/FDS.cs @@ -0,0 +1,68 @@ +using System.IO; +using SabreTools.Data.Models.NES; +using SabreTools.IO.Extensions; +using static SabreTools.Data.Models.NES.Constants; + +namespace SabreTools.Serialization.Readers +{ + public class FDS : BaseBinaryReader + { + /// + public override Data.Models.NES.FDS? 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 FDS file to fill + var fds = new Data.Models.NES.FDS(); + + #region Header + + // Try to parse the header + var header = ParseHeader(data); + if (header is null) + return null; + + // Set the header + fds.Header = header; + + #endregion + + // Read the disk data + fds.Data = data.ReadBytes((int)(data.Length - data.Position)); + + return fds; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Stream into a FDSHeader + /// + /// Stream to parse + /// Filled FDSHeader on success, null on error + public static FDSHeader? ParseHeader(Stream data) + { + var obj = new FDSHeader(); + + obj.IdentificationString = data.ReadBytes(4); + if (!obj.IdentificationString.EqualsExactly(FDSSignatureBytes)) + return null; + + obj.DiskSides = data.ReadByteValue(); + obj.Padding = data.ReadBytes(11); + + return obj; + } + } +} diff --git a/SabreTools.Serialization/Readers/NESCart.cs b/SabreTools.Serialization/Readers/NESCart.cs index 8d9abac5..804bf8d6 100644 --- a/SabreTools.Serialization/Readers/NESCart.cs +++ b/SabreTools.Serialization/Readers/NESCart.cs @@ -79,10 +79,10 @@ namespace SabreTools.Serialization.Readers } /// - /// Parse a Stream into a Header + /// Parse a Stream into a CartHeader /// /// Stream to parse - /// Filled Header on success, null on error + /// Filled CartHeader on success, null on error public static CartHeader? ParseHeader(Stream data) { // Cache data until NES 2.0 flag determined diff --git a/SabreTools.Serialization/WrapperFactory.cs b/SabreTools.Serialization/WrapperFactory.cs index 7119b1e3..57ed47d6 100644 --- a/SabreTools.Serialization/WrapperFactory.cs +++ b/SabreTools.Serialization/WrapperFactory.cs @@ -26,6 +26,7 @@ namespace SabreTools.Serialization WrapperType.CHD => CHD.Create(data), WrapperType.CIA => CIA.Create(data), WrapperType.Executable => CreateExecutableWrapper(data), + WrapperType.FDS => FDS.Create(data), WrapperType.GCF => GCF.Create(data), WrapperType.GZip => GZip.Create(data), WrapperType.IniFile => null,// TODO: Implement wrapper @@ -342,6 +343,18 @@ namespace SabreTools.Serialization #endregion + #region FDS + + // fwNES FDS file with header + if (magic.StartsWith(Data.Models.NES.Constants.FDSSignatureBytes)) + return WrapperType.FDS; + + // fwNES FDS file with header + if (extension.Equals("fds", StringComparison.OrdinalIgnoreCase)) + return WrapperType.FDS; + + #endregion + // TODO: Use constants from Models here #region GCF diff --git a/SabreTools.Serialization/Wrappers/FDS.Extraction.cs b/SabreTools.Serialization/Wrappers/FDS.Extraction.cs new file mode 100644 index 00000000..b0af4e69 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/FDS.Extraction.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using SabreTools.IO.Extensions; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class FDS : 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.IdentificationString); + fs.Flush(); + + // Byte 4 + fs.Write(Header.DiskSides); + fs.Flush(); + + // Byte 5-15 + fs.Write(Header.Padding); + fs.Flush(); + + // Header extracted + success = true; + } + catch (Exception ex) + { + if (includeDebug) Console.Error.WriteLine(ex); + } + } + + // Disk data + // TODO: Convert to QD format? + if (Model.Data.Length > 0) + { + string diskPath = $"{basePath}.unh"; + if (includeDebug) Console.WriteLine($"Attempting to extract disk data to {diskPath}"); + + // Try to write the data + try + { + // Open the output file for writing + using var fs = File.Open(diskPath, FileMode.Create, FileAccess.Write, FileShare.None); + fs.Write(Model.Data, 0, Model.Data.Length); + fs.Flush(); + + // PRG-ROM extracted + success = true; + } + catch (Exception ex) + { + if (includeDebug) Console.Error.WriteLine(ex); + } + } + + return success; + } + } +} diff --git a/SabreTools.Serialization/Wrappers/FDS.Printing.cs b/SabreTools.Serialization/Wrappers/FDS.Printing.cs new file mode 100644 index 00000000..f9cbe94a --- /dev/null +++ b/SabreTools.Serialization/Wrappers/FDS.Printing.cs @@ -0,0 +1,44 @@ +using System.Text; +using SabreTools.Data.Extensions; +using SabreTools.Data.Models.NES; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class FDS : IPrintable + { +#if NETCOREAPP + /// + public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions); +#endif + + /// + public void PrintInformation(StringBuilder builder) + { + builder.AppendLine("Nintendo Entertainment System Cart Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + Print(builder, Model.Header); + + //builder.AppendLine(Model.Data, "Disk Data"); + builder.AppendLine(Model.Data.Length, "Disk Data Length"); + } + + private static void Print(StringBuilder builder, FDSHeader? header) + { + builder.AppendLine(" FDS Header Information:"); + builder.AppendLine(" -------------------------"); + if (header is null) + { + builder.AppendLine(" No header present"); + builder.AppendLine(); + return; + } + + builder.AppendLine(header.IdentificationString, " Identification string"); + builder.AppendLine(header.DiskSides, " Disk sides"); + builder.AppendLine(header.Padding, " Padding"); + builder.AppendLine(); + } + } +} diff --git a/SabreTools.Serialization/Wrappers/FDS.cs b/SabreTools.Serialization/Wrappers/FDS.cs new file mode 100644 index 00000000..6eb17b77 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/FDS.cs @@ -0,0 +1,100 @@ +using System.IO; +using SabreTools.Data.Models.NES; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class FDS : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "fwNES FDS File"; + + #endregion + + #region Extension Properties + + /// + public FDSHeader Header => Model.Header; + + /// + public byte DiskSides => Header.DiskSides; + + #endregion + + #region Constructors + + /// + public FDS(Data.Models.NES.FDS model, byte[] data) : base(model, data) { } + + /// + public FDS(Data.Models.NES.FDS model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public FDS(Data.Models.NES.FDS model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public FDS(Data.Models.NES.FDS model, Stream data) : base(model, data) { } + + /// + public FDS(Data.Models.NES.FDS model, Stream data, long offset) : base(model, data, offset) { } + + /// + public FDS(Data.Models.NES.FDS model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an NES cart image from a byte array and offset + /// + /// Byte array representing the archive + /// Offset within the array to parse + /// An NES cart image wrapper on success, null on failure + public static FDS? 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 NES cart image from a Stream + /// + /// Stream representing the archive + /// An NES cart image wrapper on success, null on failure + public static FDS? 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.FDS().Deserialize(data); + if (model is null) + return null; + + return new FDS(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Serialization/Wrappers/WrapperType.cs b/SabreTools.Serialization/Wrappers/WrapperType.cs index 97e463d8..567cb4c1 100644 --- a/SabreTools.Serialization/Wrappers/WrapperType.cs +++ b/SabreTools.Serialization/Wrappers/WrapperType.cs @@ -71,6 +71,11 @@ namespace SabreTools.Serialization.Wrappers /// Includes MZ, NE, LE/LX, and PE Executable, + /// + /// fwNES FDS file + /// + FDS, + /// /// Half-Life Game Cache File ///