Add fwNES FDS file support

This commit is contained in:
Matt Nadareski
2026-03-10 17:27:58 -04:00
parent e8394d6665
commit 70fe830241
15 changed files with 507 additions and 3 deletions

View File

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

View File

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

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

View File

@@ -11,5 +11,15 @@ namespace SabreTools.Data.Models.NES
/// NES<EOF>
/// </summary>
public static readonly string CartSignatureString = "NES" + (char)0x1A;
/// <summary>
/// FDS<EOF>
/// </summary>
public static readonly byte[] FDSSignatureBytes = [0x46, 0x44, 0x53, 0x1A];
/// <summary>
/// FDS<EOF>
/// </summary>
public static readonly string FDSSignatureString = "FDS" + (char)0x1A;
}
}

View File

@@ -0,0 +1,19 @@
namespace SabreTools.Data.Models.NES
{
/// <summary>
/// fwNES FDS file
/// </summary>
/// <see href="https://www.nesdev.org/wiki/FDS_file_format"/>
public class FDS
{
/// <summary>
/// FDS header
/// </summary>
public FDSHeader? Header { get; set; }
/// <summary>
/// Disk data (65500 * x bytes)
/// </summary>
public byte[] Data { get; set; } = [];
}
}

View File

@@ -0,0 +1,24 @@
namespace SabreTools.Data.Models.NES
{
/// <summary>
/// fwNES FDS header
/// </summary>
/// <see href="https://www.nesdev.org/wiki/FDS_file_format"/>
public class FDSHeader
{
/// <summary>
/// Constant $46 $44 $53 $1A ("FDS" followed by MS-DOS end-of-file)
/// </summary>
public byte[] IdentificationString { get; set; } = new byte[4];
/// <summary>
/// Number of disk sides
/// </summary>
public byte DiskSides { get; set; }
/// <summary>
/// Zero-filled padding
/// </summary>
public byte[] Padding { get; set; } = new byte[11];
}
}

View File

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

View File

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

View File

@@ -79,10 +79,10 @@ namespace SabreTools.Serialization.Readers
}
/// <summary>
/// Parse a Stream into a Header
/// Parse a Stream into a CartHeader
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Header on success, null on error</returns>
/// <returns>Filled CartHeader on success, null on error</returns>
public static CartHeader? ParseHeader(Stream data)
{
// Cache data until NES 2.0 flag determined

View File

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

View File

@@ -0,0 +1,81 @@
using System;
using System.IO;
using SabreTools.IO.Extensions;
namespace SabreTools.Serialization.Wrappers
{
public partial class FDS : 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.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;
}
}
}

View File

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

View File

@@ -0,0 +1,100 @@
using System.IO;
using SabreTools.Data.Models.NES;
namespace SabreTools.Serialization.Wrappers
{
public partial class FDS : WrapperBase<Data.Models.NES.FDS>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "fwNES FDS File";
#endregion
#region Extension Properties
/// <inheritdoc cref="Data.Models.NES.FDS.Header"/>
public FDSHeader Header => Model.Header;
/// <inheritdoc cref="FDSHeader.DiskSides"/>
public byte DiskSides => Header.DiskSides;
#endregion
#region Constructors
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public FDS(Data.Models.NES.FDS model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create an NES 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 NES cart image wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create an NES cart image from a Stream
/// </summary>
/// <param name="data">Stream representing the archive</param>
/// <returns>An NES cart image wrapper on success, null on failure</returns>
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
}
}

View File

@@ -71,6 +71,11 @@ namespace SabreTools.Serialization.Wrappers
/// <remarks>Includes MZ, NE, LE/LX, and PE</remarks>
Executable,
/// <summary>
/// fwNES FDS file
/// </summary>
FDS,
/// <summary>
/// Half-Life Game Cache File
/// </summary>