From 87108405a8301d00d029f6c955585953d36d496f Mon Sep 17 00:00:00 2001 From: Matt Nadareski Date: Sun, 15 Jan 2023 23:33:09 -0800 Subject: [PATCH] Add PFF support (full) --- BurnOutSharp.Builders/PFF.cs | 206 ++++++++++++++++++++ BurnOutSharp.Models/PFF/Archive.cs | 24 +++ BurnOutSharp.Models/PFF/Constants.cs | 21 +++ BurnOutSharp.Models/PFF/Footer.cs | 24 +++ BurnOutSharp.Models/PFF/Header.cs | 36 ++++ BurnOutSharp.Models/PFF/Segment.cs | 46 +++++ BurnOutSharp.Wrappers/PFF.cs | 273 +++++++++++++++++++++++++++ BurnOutSharp/Enums.cs | 5 + BurnOutSharp/FileType/PFF.cs | 69 +++++++ BurnOutSharp/Scanner.cs | 8 + BurnOutSharp/Tools/Utilities.cs | 28 ++- README.md | 1 + Test/Extractor.cs | 29 ++- Test/Printer.cs | 6 + 14 files changed, 773 insertions(+), 3 deletions(-) create mode 100644 BurnOutSharp.Builders/PFF.cs create mode 100644 BurnOutSharp.Models/PFF/Archive.cs create mode 100644 BurnOutSharp.Models/PFF/Constants.cs create mode 100644 BurnOutSharp.Models/PFF/Footer.cs create mode 100644 BurnOutSharp.Models/PFF/Header.cs create mode 100644 BurnOutSharp.Models/PFF/Segment.cs create mode 100644 BurnOutSharp.Wrappers/PFF.cs create mode 100644 BurnOutSharp/FileType/PFF.cs diff --git a/BurnOutSharp.Builders/PFF.cs b/BurnOutSharp.Builders/PFF.cs new file mode 100644 index 00000000..cf2b25d3 --- /dev/null +++ b/BurnOutSharp.Builders/PFF.cs @@ -0,0 +1,206 @@ +using System.IO; +using System.Text; +using BurnOutSharp.Models.PFF; +using BurnOutSharp.Utilities; +using static BurnOutSharp.Models.PFF.Constants; + +namespace BurnOutSharp.Builders +{ + public class PFF + { + #region Byte Data + + /// + /// Parse a byte array into a PFF archive + /// + /// Byte array to parse + /// Offset into the byte array + /// Filled archive on success, null on error + public static Archive ParseArchive(byte[] data, int offset) + { + // If the data is invalid + if (data == null) + return null; + + // If the offset is out of bounds + if (offset < 0 || offset >= data.Length) + return null; + + // Create a memory stream and parse that + MemoryStream dataStream = new MemoryStream(data, offset, data.Length - offset); + return ParseArchive(dataStream); + } + + #endregion + + #region Stream Data + + /// + /// Parse a Stream into a PFF archive + /// + /// Stream to parse + /// Filled archive on success, null on error + public static Archive ParseArchive(Stream data) + { + // If the data is invalid + if (data == null || data.Length == 0 || !data.CanSeek || !data.CanRead) + return null; + + // If the offset is out of bounds + if (data.Position < 0 || data.Position >= data.Length) + return null; + + // Cache the current offset + int initialOffset = (int)data.Position; + + // Create a new archive to fill + var archive = new Archive(); + + #region Header + + // Try to parse the header + var header = ParseHeader(data); + if (header == null) + return null; + + // Set the archive header + archive.Header = header; + + #endregion + + #region Segments + + // Get the segments + long offset = header.FileListOffset; + if (offset < 0 || offset >= data.Length) + return null; + + // Seek to the segments + data.Seek(offset, SeekOrigin.Begin); + + // Create the segments array + archive.Segments = new Segment[header.NumberOfFiles]; + + // Read all segments in turn + for (int i = 0; i < header.NumberOfFiles; i++) + { + var file = ParseSegment(data, header.FileSegmentSize); + if (file == null) + return null; + + archive.Segments[i] = file; + } + + #endregion + + #region Footer + + // Get the footer offset + offset = header.FileListOffset + (header.FileSegmentSize * header.NumberOfFiles); + if (offset < 0 || offset >= data.Length) + return null; + + // Seek to the footer + data.Seek(offset, SeekOrigin.Begin); + + // Try to parse the footer + var footer = ParseFooter(data); + if (footer == null) + return null; + + // Set the archive footer + archive.Footer = footer; + + #endregion + + return archive; + } + + /// + /// Parse a Stream into a header + /// + /// Stream to parse + /// Filled header on success, null on error + private static Header ParseHeader(Stream data) + { + // TODO: Use marshalling here instead of building + Header header = new Header(); + + header.HeaderSize = data.ReadUInt32(); + byte[] signature = data.ReadBytes(4); + header.Signature = Encoding.ASCII.GetString(signature); + header.NumberOfFiles = data.ReadUInt32(); + header.FileSegmentSize = data.ReadUInt32(); + switch (header.Signature) + { + case Version2SignatureString: + if (header.FileSegmentSize != Version2SegmentSize) + return null; + break; + + // Version 3 can sometimes have Version 2 segment sizes + case Version3SignatureString: + if (header.FileSegmentSize != Version2SegmentSize && header.FileSegmentSize != Version3SegmentSize) + return null; + break; + + case Version4SignatureString: + if (header.FileSegmentSize != Version4SegmentSize) + return null; + break; + + default: + return null; + } + + header.FileListOffset = data.ReadUInt32(); + + return header; + } + + /// + /// Parse a Stream into a footer + /// + /// Stream to parse + /// Filled footer on success, null on error + private static Footer ParseFooter(Stream data) + { + // TODO: Use marshalling here instead of building + Footer footer = new Footer(); + + footer.SystemIP = data.ReadUInt32(); + footer.Reserved = data.ReadUInt32(); + byte[] kingTag = data.ReadBytes(4); + footer.KingTag = Encoding.ASCII.GetString(kingTag); + + return footer; + } + + /// + /// Parse a Stream into a file entry + /// + /// Stream to parse + /// PFF segment size + /// Filled file entry on success, null on error + private static Segment ParseSegment(Stream data, uint segmentSize) + { + // TODO: Use marshalling here instead of building + Segment segment = new Segment(); + + segment.Deleted = data.ReadUInt32(); + segment.FileLocation = data.ReadUInt32(); + segment.FileSize = data.ReadUInt32(); + segment.PackedDate = data.ReadUInt32(); + byte[] fileName = data.ReadBytes(0x10); + segment.FileName = Encoding.ASCII.GetString(fileName).TrimEnd('\0'); + if (segmentSize > Version2SegmentSize) + segment.ModifiedDate = data.ReadUInt32(); + if (segmentSize > Version3SegmentSize) + segment.CompressionLevel = data.ReadUInt32(); + + return segment; + } + + #endregion + } +} diff --git a/BurnOutSharp.Models/PFF/Archive.cs b/BurnOutSharp.Models/PFF/Archive.cs new file mode 100644 index 00000000..3dc04e13 --- /dev/null +++ b/BurnOutSharp.Models/PFF/Archive.cs @@ -0,0 +1,24 @@ +namespace BurnOutSharp.Models.PFF +{ + /// + /// PFF archive + /// + /// + public sealed class Archive + { + /// + /// Archive header + /// + public Header Header { get; set; } + + /// + /// Segments + /// + public Segment[] Segments { get; set; } + + /// + /// Footer + /// + public Footer Footer { get; set; } + } +} \ No newline at end of file diff --git a/BurnOutSharp.Models/PFF/Constants.cs b/BurnOutSharp.Models/PFF/Constants.cs new file mode 100644 index 00000000..3b43ae26 --- /dev/null +++ b/BurnOutSharp.Models/PFF/Constants.cs @@ -0,0 +1,21 @@ +namespace BurnOutSharp.Models.PFF +{ + /// + public static class Constants + { + // Version 1 not confirmed + // public const string Version1SignatureString = "PFF1"; + // public const uint Version1HeaderSize = 0x00000000; + + public const string Version2SignatureString = "PFF2"; + public const uint Version2SegmentSize = 0x00000020; + + public const string Version3SignatureString = "PFF3"; + public const uint Version3SegmentSize = 0x00000024; + + public const string Version4SignatureString = "PFF4"; + public const uint Version4SegmentSize = 0x00000028; + + public const string FooterKingTag = "KING"; + } +} \ No newline at end of file diff --git a/BurnOutSharp.Models/PFF/Footer.cs b/BurnOutSharp.Models/PFF/Footer.cs new file mode 100644 index 00000000..c47f8bbd --- /dev/null +++ b/BurnOutSharp.Models/PFF/Footer.cs @@ -0,0 +1,24 @@ +namespace BurnOutSharp.Models.PFF +{ + /// + /// PFF file footer + /// + /// + public sealed class Footer + { + /// + /// Current system IP + /// + public uint SystemIP; + + /// + /// Reserved + /// + public uint Reserved; + + /// + /// King tag + /// + public string KingTag; + } +} \ No newline at end of file diff --git a/BurnOutSharp.Models/PFF/Header.cs b/BurnOutSharp.Models/PFF/Header.cs new file mode 100644 index 00000000..32b21fc9 --- /dev/null +++ b/BurnOutSharp.Models/PFF/Header.cs @@ -0,0 +1,36 @@ +namespace BurnOutSharp.Models.PFF +{ + /// + /// PFF archive header + /// + /// Versions 2, 3, and 4 supported + /// + public sealed class Header + { + /// + /// Size of the following header + /// + public uint HeaderSize; + + /// + /// Signature + /// + /// Versions 2 and 3 share the same signature but different header sizes + public string Signature; + + /// + /// Number of files + /// + public uint NumberOfFiles; + + /// + /// File segment size + /// + public uint FileSegmentSize; + + /// + /// File list offset + /// + public uint FileListOffset; + } +} \ No newline at end of file diff --git a/BurnOutSharp.Models/PFF/Segment.cs b/BurnOutSharp.Models/PFF/Segment.cs new file mode 100644 index 00000000..9b38cb4d --- /dev/null +++ b/BurnOutSharp.Models/PFF/Segment.cs @@ -0,0 +1,46 @@ +namespace BurnOutSharp.Models.PFF +{ + /// + /// PFF segment identifier + /// + /// + public sealed class Segment + { + /// + /// Deleted flag + /// + public uint Deleted; + + /// + /// File location + /// + public uint FileLocation; + + /// + /// File size + /// + public uint FileSize; + + /// + /// Packed date + /// + public uint PackedDate; + + /// + /// File name + /// + public string FileName; + + /// + /// Modified date + /// + /// Only for versions 3 and 4 + public uint ModifiedDate; + + /// + /// Compression level + /// + /// Only for version 4 + public uint CompressionLevel; + } +} \ No newline at end of file diff --git a/BurnOutSharp.Wrappers/PFF.cs b/BurnOutSharp.Wrappers/PFF.cs new file mode 100644 index 00000000..411eab54 --- /dev/null +++ b/BurnOutSharp.Wrappers/PFF.cs @@ -0,0 +1,273 @@ +using System.IO; +using System.Text; + +namespace BurnOutSharp.Wrappers +{ + public class PFF : WrapperBase + { + #region Pass-Through Properties + + #region Header + + /// + public uint HeaderSize => _archive.Header.HeaderSize; + + /// + public string Signature => _archive.Header.Signature; + + /// + public uint NumberOfFiles => _archive.Header.NumberOfFiles; + + /// + public uint FileSegmentSize => _archive.Header.FileSegmentSize; + + /// + public uint FileListOffset => _archive.Header.FileListOffset; + + #endregion + + #region Segments + + /// + public Models.PFF.Segment[] Segments => _archive.Segments; + + #endregion + + #region Footer + + /// + public uint SystemIP => _archive.Footer.SystemIP; + + /// + public uint Reserved => _archive.Footer.Reserved; + + /// + public string KingTag => _archive.Footer.KingTag; + + #endregion + + #endregion + + #region Instance Variables + + /// + /// Internal representation of the archive + /// + private Models.PFF.Archive _archive; + + #endregion + + #region Constructors + + /// + /// Private constructor + /// + private PFF() { } + + /// + /// Create a PFF archive from a byte array and offset + /// + /// Byte array representing the archive + /// Offset within the array to parse + /// A PFF archive wrapper on success, null on failure + public static PFF Create(byte[] data, int offset) + { + // If the data is invalid + if (data == null) + return null; + + // If the offset is out of bounds + if (offset < 0 || offset >= data.Length) + return null; + + // Create a memory stream and use that + MemoryStream dataStream = new MemoryStream(data, offset, data.Length - offset); + return Create(dataStream); + } + + /// + /// Create a PFF archive from a Stream + /// + /// Stream representing the archive + /// A PFF archive wrapper on success, null on failure + public static PFF Create(Stream data) + { + // If the data is invalid + if (data == null || data.Length == 0 || !data.CanSeek || !data.CanRead) + return null; + + var archive = Builders.PFF.ParseArchive(data); + if (archive == null) + return null; + + var wrapper = new PFF + { + _archive = archive, + _dataSource = DataSource.Stream, + _streamData = data, + }; + return wrapper; + } + + #endregion + + #region Data + + /// + /// Extract all segments from the PFF to an output directory + /// + /// Output directory to write to + /// True if all segments extracted, false otherwise + public bool ExtractAll(string outputDirectory) + { + // If we have no segments + if (Segments == null || Segments.Length == 0) + return false; + + // Loop through and extract all files to the output + bool allExtracted = true; + for (int i = 0; i < Segments.Length; i++) + { + allExtracted &= ExtractSegment(i, outputDirectory); + } + + return allExtracted; + } + + /// + /// Extract a segment from the PFF to an output directory by index + /// + /// Segment index to extract + /// Output directory to write to + /// True if the segment extracted, false otherwise + public bool ExtractSegment(int index, string outputDirectory) + { + // If we have no segments + if (NumberOfFiles == 0 || Segments == null || Segments.Length == 0) + return false; + + // If we have an invalid index + if (index < 0 || index >= Segments.Length) + return false; + + // Get the segment information + var file = Segments[index]; + + // Get the read index and length + int offset = (int)file.FileLocation; + int size = (int)file.FileSize; + + try + { + // Ensure the output directory exists + Directory.CreateDirectory(outputDirectory); + + // Create the output path + string filePath = Path.Combine(outputDirectory, file.FileName); + using (FileStream fs = File.OpenWrite(filePath)) + { + // Read the data block + byte[] data = ReadFromDataSource(offset, size); + + // Write the data -- TODO: Compressed data? + fs.Write(data, 0, size); + } + + return true; + } + catch + { + return false; + } + } + + #endregion + + #region Printing + + /// + public override StringBuilder PrettyPrint() + { + StringBuilder builder = new StringBuilder(); + + builder.AppendLine("PFF Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + PrintHeader(builder); + PrintSegments(builder); + PrintFooter(builder); + + return builder; + } + + /// + /// Print header information + /// + /// StringBuilder to append information to + private void PrintHeader(StringBuilder builder) + { + builder.AppendLine(" Header Information:"); + builder.AppendLine(" -------------------------"); + builder.AppendLine($" Header size: {HeaderSize} (0x{HeaderSize:X})"); + builder.AppendLine($" Signature: {Signature}"); + builder.AppendLine($" Number of files: {NumberOfFiles} (0x{NumberOfFiles:X})"); + builder.AppendLine($" File segment size: {FileSegmentSize} (0x{FileSegmentSize:X})"); + builder.AppendLine($" File list offset: {FileListOffset} (0x{FileListOffset:X})"); + builder.AppendLine(); + } + + /// + /// Print segmentsinformation + /// + /// StringBuilder to append information to + private void PrintSegments(StringBuilder builder) + { + builder.AppendLine(" Segments Information:"); + builder.AppendLine(" -------------------------"); + if (NumberOfFiles == 0 || Segments == null || Segments.Length == 0) + { + builder.AppendLine(" No segments"); + } + else + { + for (int i = 0; i < Segments.Length; i++) + { + var segment = Segments[i]; + builder.AppendLine($" Segment {i}"); + builder.AppendLine($" Deleted: {segment.Deleted} (0x{segment.Deleted:X})"); + builder.AppendLine($" File location: {segment.FileLocation} (0x{segment.FileLocation:X})"); + builder.AppendLine($" File size: {segment.FileSize} (0x{segment.FileSize:X})"); + builder.AppendLine($" Packed date: {segment.PackedDate} (0x{segment.PackedDate:X})"); + builder.AppendLine($" File name: {segment.FileName ?? "[NULL]"}"); + builder.AppendLine($" Modified date: {segment.ModifiedDate} (0x{segment.ModifiedDate:X})"); + builder.AppendLine($" Compression level: {segment.CompressionLevel} (0x{segment.CompressionLevel:X})"); + } + } + builder.AppendLine(); + } + + /// + /// Print footer information + /// + /// StringBuilder to append information to + private void PrintFooter(StringBuilder builder) + { + builder.AppendLine(" Footer Information:"); + builder.AppendLine(" -------------------------"); + builder.AppendLine($" System IP: {SystemIP} (0x{SystemIP:X})"); + builder.AppendLine($" Reserved: {Reserved} (0x{Reserved:X})"); + builder.AppendLine($" King tag: {KingTag ?? "[NULL]"}"); + builder.AppendLine(); + } + +#if NET6_0_OR_GREATER + + /// + public override string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(_archive, _jsonSerializerOptions); + +#endif + + #endregion + } +} \ No newline at end of file diff --git a/BurnOutSharp/Enums.cs b/BurnOutSharp/Enums.cs index 118dd818..7ebfd4f6 100644 --- a/BurnOutSharp/Enums.cs +++ b/BurnOutSharp/Enums.cs @@ -115,6 +115,11 @@ /// PAK, + /// + /// NovaLogic Game Archive Format + /// + PFF, + /// /// PKWARE ZIP archive and derivatives /// diff --git a/BurnOutSharp/FileType/PFF.cs b/BurnOutSharp/FileType/PFF.cs new file mode 100644 index 00000000..bc6fa3a6 --- /dev/null +++ b/BurnOutSharp/FileType/PFF.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using BurnOutSharp.Interfaces; +using static BurnOutSharp.Utilities.Dictionary; + +namespace BurnOutSharp.FileType +{ + /// + /// NovaLogic Game Archive Format + /// + public class PFF : IScannable + { + /// + public ConcurrentDictionary> Scan(Scanner scanner, string file) + { + if (!File.Exists(file)) + return null; + + using (var fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + return Scan(scanner, fs, file); + } + } + + /// + public ConcurrentDictionary> Scan(Scanner scanner, Stream stream, string file) + { + // If the PFF file itself fails + try + { + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempPath); + + // Create the wrapper + Wrappers.PFF bfpk = Wrappers.PFF.Create(stream); + if (bfpk == null) + return null; + + // Extract all files + bfpk.ExtractAll(tempPath); + + // Collect and format all found protections + var protections = scanner.GetProtections(tempPath); + + // If temp directory cleanup fails + try + { + Directory.Delete(tempPath, true); + } + catch (Exception ex) + { + if (scanner.IncludeDebug) Console.WriteLine(ex); + } + + // Remove temporary path references + StripFromKeys(protections, tempPath); + + return protections; + } + catch (Exception ex) + { + if (scanner.IncludeDebug) Console.WriteLine(ex); + } + + return null; + } + } +} diff --git a/BurnOutSharp/Scanner.cs b/BurnOutSharp/Scanner.cs index 1f9f7a9d..3c1d2bd6 100644 --- a/BurnOutSharp/Scanner.cs +++ b/BurnOutSharp/Scanner.cs @@ -513,6 +513,14 @@ namespace BurnOutSharp AppendToDictionary(protections, subProtections); } + // PFF + if (fileName != null && scannable is PFF) + { + var subProtections = scannable.Scan(this, fileName); + PrependToKeys(subProtections, fileName); + AppendToDictionary(protections, subProtections); + } + // PKZIP archive (and derivatives) if (scannable is PKZIP) { diff --git a/BurnOutSharp/Tools/Utilities.cs b/BurnOutSharp/Tools/Utilities.cs index 4ec16983..84f10f27 100644 --- a/BurnOutSharp/Tools/Utilities.cs +++ b/BurnOutSharp/Tools/Utilities.cs @@ -202,6 +202,22 @@ namespace BurnOutSharp.Tools #endregion + #region PFF + + // Version 2 + if (magic.StartsWith(new byte?[] { 0x14, 0x00, 0x00, 0x00, 0x50, 0x46, 0x46, 0x32 })) + return SupportedFileType.PFF; + + // Version 3 + if (magic.StartsWith(new byte?[] { 0x14, 0x00, 0x00, 0x00, 0x50, 0x46, 0x46, 0x33 })) + return SupportedFileType.PFF; + + // Version 4 + if (magic.StartsWith(new byte?[] { 0x14, 0x00, 0x00, 0x00, 0x50, 0x46, 0x46, 0x34 })) + return SupportedFileType.PFF; + + #endregion + #region PKZIP // PKZIP (Unknown) @@ -382,7 +398,7 @@ namespace BurnOutSharp.Tools return SupportedFileType.BDPlusSVM; #endregion - + #region BFPK // No extensions registered for BFPK @@ -434,7 +450,7 @@ namespace BurnOutSharp.Tools return SupportedFileType.CIA; #endregion - + #region Executable // DOS MZ executable file format (and descendants) @@ -544,6 +560,13 @@ namespace BurnOutSharp.Tools #endregion + #region PFF + + if (extension.Equals("pff", StringComparison.OrdinalIgnoreCase)) + return SupportedFileType.PFF; + + #endregion + #region PKZIP // PKZIP @@ -791,6 +814,7 @@ namespace BurnOutSharp.Tools //case SupportedFileType.NCF: return new FileType.NCF(); //case SupportedFileType.Nitro: return new FileType.Nitro(); case SupportedFileType.PAK: return new FileType.PAK(); + case SupportedFileType.PFF: return new FileType.PFF(); case SupportedFileType.PKZIP: return new FileType.PKZIP(); case SupportedFileType.PLJ: return new FileType.PLJ(); //case SupportedFileType.Quantum: return new FileType.Quantum(); diff --git a/README.md b/README.md index 400aae56..3a52afc6 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ Below is a list of container formats that are supported in some way: | Nintendo 3DS cart image | Yes | Yes | No | | | Nintendo CIA archive | Yes | Yes | No | | | Nintendo DS/DSi cart image | Yes | Yes | No | | +| NovaLogic Game Archive Format (PFF) | Yes | Yes | Yes | | | PKZIP and derived files (ZIP, etc.) | No | Yes | Yes | Via `SharpCompress` | | PlayJ audio file (PLJ) | Yes* | Yes | No | Undocumented file format, many fields printed | | Portable Executable | Yes | Yes | No* | Some packed executables are supported | diff --git a/Test/Extractor.cs b/Test/Extractor.cs index cafb7f4a..ee17b0b8 100644 --- a/Test/Extractor.cs +++ b/Test/Extractor.cs @@ -532,7 +532,34 @@ namespace Test } catch (Exception ex) { - Console.WriteLine($"Something went wrong extracting MS-CAB: {ex}"); + Console.WriteLine($"Something went wrong extracting PAK: {ex}"); + Console.WriteLine(); + } + } + + // PFF + else if (ft == SupportedFileType.PFF) + { + // Build the archive information + Console.WriteLine("Extracting PFF contents"); + Console.WriteLine(); + + var pff = PFF.Create(stream); + if (pff == null) + { + Console.WriteLine("Something went wrong parsing PFF"); + Console.WriteLine(); + return; + } + + try + { + // Extract the PFF contents to the directory + pff.ExtractAll(outputDirectory); + } + catch (Exception ex) + { + Console.WriteLine($"Something went wrong extracting PFF: {ex}"); Console.WriteLine(); } } diff --git a/Test/Printer.cs b/Test/Printer.cs index 55da7e75..d18351a1 100644 --- a/Test/Printer.cs +++ b/Test/Printer.cs @@ -195,6 +195,12 @@ namespace Test wrapper = PAK.Create(stream); break; + // PFF + case SupportedFileType.PFF: + wrapperName = "NovaLogic Game Archive Format"; + wrapper = PFF.Create(stream); + break; + // PLJ case SupportedFileType.PLJ: wrapperName = "PlayJ audio file";