diff --git a/BurnOutSharp.Builders/PlayJ.cs b/BurnOutSharp.Builders/PlayJ.cs
new file mode 100644
index 00000000..cb2afc7b
--- /dev/null
+++ b/BurnOutSharp.Builders/PlayJ.cs
@@ -0,0 +1,328 @@
+using System.IO;
+using System.Text;
+using BurnOutSharp.Models.PlayJ;
+using BurnOutSharp.Utilities;
+using static BurnOutSharp.Models.PlayJ.Constants;
+
+namespace BurnOutSharp.Builders
+{
+ public class PlayJ
+ {
+ #region Byte Data
+
+ ///
+ /// Parse a byte array into a PlayJ playlist
+ ///
+ /// Byte array to parse
+ /// Offset into the byte array
+ /// Filled playlist on success, null on error
+ public static Playlist ParsePlaylist(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 ParsePlaylist(dataStream);
+ }
+
+ ///
+ /// Parse a byte array into a PlayJ audio file
+ ///
+ /// Byte array to parse
+ /// Offset into the byte array
+ /// Filled audio file on success, null on error
+ public static AudioFile ParseAudioFile(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 ParseAudioFile(dataStream);
+ }
+
+ #endregion
+
+ #region Stream Data
+
+ ///
+ /// Parse a Stream into a PlayJ playlist
+ ///
+ /// Stream to parse
+ /// Filled playlist on success, null on error
+ public static Playlist ParsePlaylist(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 playlist to fill
+ var playlist = new Playlist();
+
+ #region Playlist Header
+
+ // Try to parse the playlist header
+ var playlistHeader = ParsePlaylistHeader(data);
+ if (playlistHeader == null)
+ return null;
+
+ // Set the playlist header
+ playlist.Header = playlistHeader;
+
+ #endregion
+
+ #region Audio Files
+
+ // Create the audio files array
+ playlist.AudioFiles = new AudioFile[playlistHeader.TrackCount];
+
+ // Try to parse the audio files
+ for (int i = 0; i < playlist.AudioFiles.Length; i++)
+ {
+ long currentOffset = data.Position;
+ var entryHeader = ParseAudioFile(data, currentOffset);
+ if (entryHeader == null)
+ return null;
+
+ playlist.AudioFiles[i] = entryHeader;
+ }
+
+ #endregion
+
+ return playlist;
+ }
+
+ ///
+ /// Parse a Stream into a PlayJ audio file
+ ///
+ /// Stream to parse
+ /// Offset to adjust all seeking by
+ /// Filled audio file on success, null on error
+ public static AudioFile ParseAudioFile(Stream data, long adjust = 0)
+ {
+ // 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 audio file to fill
+ var audioFile = new AudioFile();
+
+ #region Entry Header
+
+ // Try to parse the entry header
+ var entryHeader = ParseEntryHeader(data);
+ if (entryHeader == null)
+ return null;
+
+ // Set the entry header
+ audioFile.Header = entryHeader;
+
+ #endregion
+
+ #region Unknown Block 1
+
+ // Get the unknown block 1 offset
+ long offset = entryHeader.UnknownOffset1 + adjust;
+ if (offset < 0 || offset >= data.Length)
+ return null;
+
+ // Seek to the unknown block 1
+ data.Seek(offset, SeekOrigin.Begin);
+
+ // Try to parse the unknown block 1
+ var unknownBlock1 = ParseUnknownBlock1(data);
+ if (unknownBlock1 == null)
+ return null;
+
+ // Set the unknown block 1
+ audioFile.UnknownBlock1 = unknownBlock1;
+
+ #endregion
+
+ #region Unknown Value 2
+
+ // Get the unknown value 2 offset
+ offset = entryHeader.UnknownOffset2 + adjust;
+ if (offset < 0 || offset >= data.Length)
+ return null;
+
+ // Seek to the unknown value 2
+ data.Seek(offset, SeekOrigin.Begin);
+
+ // Set the unknown value 2
+ audioFile.UnknownValue2 = data.ReadUInt32();;
+
+ #endregion
+
+ #region Unknown Block 3
+
+ // Get the unknown block 3 offset
+ offset = entryHeader.UnknownOffset3 + adjust;
+ if (offset < 0 || offset >= data.Length)
+ return null;
+
+ // Seek to the unknown block 3
+ data.Seek(offset, SeekOrigin.Begin);
+
+ // Try to parse the unknown block 3
+ var unknownBlock3 = ParseUnknownBlock3(data);
+ if (unknownBlock3 == null)
+ return null;
+
+ // Set the unknown block 3
+ audioFile.UnknownBlock3 = unknownBlock3;
+
+ #endregion
+
+ return audioFile;
+ }
+
+ ///
+ /// Parse a Stream into a playlist header
+ ///
+ /// Stream to parse
+ /// Filled playlist header on success, null on error
+ private static PlaylistHeader ParsePlaylistHeader(Stream data)
+ {
+ // TODO: Use marshalling here instead of building
+ PlaylistHeader playlistHeader = new PlaylistHeader();
+
+ playlistHeader.TrackCount = data.ReadUInt32();
+ playlistHeader.Data = data.ReadBytes(52);
+
+ return playlistHeader;
+ }
+
+ ///
+ /// Parse a Stream into an entry header
+ ///
+ /// Stream to parse
+ /// Filled entry header on success, null on error
+ private static EntryHeader ParseEntryHeader(Stream data)
+ {
+ // Cache the current offset
+ long initialOffset = data.Position;
+
+ // TODO: Use marshalling here instead of building
+ EntryHeader entryHeader = new EntryHeader();
+
+ entryHeader.Signature = data.ReadUInt32();
+ if (entryHeader.Signature != SignatureUInt32)
+ return null;
+
+ // Only V1 is supported currently
+ entryHeader.Version = data.ReadUInt32();
+ if (entryHeader.Version != 0x00000000)
+ return null;
+
+ entryHeader.TrackID = data.ReadUInt32();
+ entryHeader.UnknownOffset1 = data.ReadUInt32();
+ entryHeader.UnknownOffset2 = data.ReadUInt32();
+ entryHeader.UnknownOffset3 = data.ReadUInt32();
+ entryHeader.Unknown1 = data.ReadUInt32();
+ entryHeader.Unknown2 = data.ReadUInt32();
+ entryHeader.Year = data.ReadUInt32();
+ entryHeader.TrackNumber = data.ReadByteValue();
+ entryHeader.Subgenre = (Subgenre)data.ReadByteValue();
+ entryHeader.Duration = data.ReadUInt32();
+
+ entryHeader.TrackLength = data.ReadUInt16();
+ byte[] track = data.ReadBytes(entryHeader.TrackLength);
+ if (track != null)
+ entryHeader.Track = Encoding.ASCII.GetString(track);
+
+ entryHeader.ArtistLength = data.ReadUInt16();
+ byte[] artist = data.ReadBytes(entryHeader.ArtistLength);
+ if (artist != null)
+ entryHeader.Artist = Encoding.ASCII.GetString(artist);
+
+ entryHeader.AlbumLength = data.ReadUInt16();
+ byte[] album = data.ReadBytes(entryHeader.AlbumLength);
+ if (album != null)
+ entryHeader.Album = Encoding.ASCII.GetString(album);
+
+ entryHeader.WriterLength = data.ReadUInt16();
+ byte[] writer = data.ReadBytes(entryHeader.WriterLength);
+ if (writer != null)
+ entryHeader.Writer = Encoding.ASCII.GetString(writer);
+
+ entryHeader.PublisherLength = data.ReadUInt16();
+ byte[] publisher = data.ReadBytes(entryHeader.PublisherLength);
+ if (publisher != null)
+ entryHeader.Publisher = Encoding.ASCII.GetString(publisher);
+
+ entryHeader.LabelLength = data.ReadUInt16();
+ byte[] label = data.ReadBytes(entryHeader.LabelLength);
+ if (label != null)
+ entryHeader.Label = Encoding.ASCII.GetString(label);
+
+ if (data.Position - initialOffset < entryHeader.UnknownOffset1)
+ {
+ entryHeader.CommentsLength = data.ReadUInt16();
+ byte[] comments = data.ReadBytes(entryHeader.CommentsLength);
+ if (comments != null)
+ entryHeader.Comments = Encoding.ASCII.GetString(comments);
+ }
+
+ return entryHeader;
+ }
+
+ ///
+ /// Parse a Stream into an unknown block 1
+ ///
+ /// Stream to parse
+ /// Filled unknown block 1 on success, null on error
+ private static UnknownBlock1 ParseUnknownBlock1(Stream data)
+ {
+ // TODO: Use marshalling here instead of building
+ UnknownBlock1 unknownBlock1 = new UnknownBlock1();
+
+ unknownBlock1.Length = data.ReadUInt16();
+ unknownBlock1.Data = data.ReadBytes(unknownBlock1.Length);
+
+ return unknownBlock1;
+ }
+
+ ///
+ /// Parse a Stream into an unknown block 3
+ ///
+ /// Stream to parse
+ /// Filled unknown block 3 on success, null on error
+ private static UnknownBlock3 ParseUnknownBlock3(Stream data)
+ {
+ // TODO: Use marshalling here instead of building
+ UnknownBlock3 unknownBlock3 = new UnknownBlock3();
+
+ // No-op because we don't even know the length
+
+ return unknownBlock3;
+ }
+
+ #endregion
+ }
+}
diff --git a/BurnOutSharp.Models/PlayJ/Playlist.cs b/BurnOutSharp.Models/PlayJ/Playlist.cs
index 57557003..713b7cca 100644
--- a/BurnOutSharp.Models/PlayJ/Playlist.cs
+++ b/BurnOutSharp.Models/PlayJ/Playlist.cs
@@ -8,11 +8,11 @@ namespace BurnOutSharp.Models.PlayJ
///
/// Playlist header
///
- public PlaylistHeader PlaylistHeader { get; set; }
+ public PlaylistHeader Header { get; set; }
///
- /// Entry headers
+ /// Embedded audio files / headers
///
- public EntryHeader[] EntryHeaders { get; set; }
+ public AudioFile[] AudioFiles { get; set; }
}
}
\ No newline at end of file
diff --git a/BurnOutSharp.Wrappers/PlayJAudioFile.cs b/BurnOutSharp.Wrappers/PlayJAudioFile.cs
new file mode 100644
index 00000000..6804151e
--- /dev/null
+++ b/BurnOutSharp.Wrappers/PlayJAudioFile.cs
@@ -0,0 +1,284 @@
+using System;
+using System.IO;
+using System.Text;
+
+namespace BurnOutSharp.Wrappers
+{
+ public class PlayJAudioFile : WrapperBase
+ {
+ #region Pass-Through Properties
+
+ #region Entry Header
+
+ ///
+ public uint Signature => _audioFile.Header.Signature;
+
+ ///
+ public uint Version => _audioFile.Header.Version;
+
+ ///
+ public uint TrackID => _audioFile.Header.TrackID;
+
+ ///
+ public uint UnknownOffset1 => _audioFile.Header.UnknownOffset1;
+
+ ///
+ public uint UnknownOffset2 => _audioFile.Header.UnknownOffset2;
+
+ ///
+ public uint UnknownOffset3 => _audioFile.Header.UnknownOffset3;
+
+ ///
+ public uint Unknown1 => _audioFile.Header.Unknown1;
+
+ ///
+ public uint Unknown2 => _audioFile.Header.Unknown2;
+
+ ///
+ public uint Year => _audioFile.Header.Year;
+
+ ///
+ public byte TrackNumber => _audioFile.Header.TrackNumber;
+
+ ///
+ public Models.PlayJ.Subgenre Subgenre => _audioFile.Header.Subgenre;
+
+ ///
+ public uint Duration => _audioFile.Header.Duration;
+
+ ///
+ public ushort TrackLength => _audioFile.Header.TrackLength;
+
+ ///
+ public string Track => _audioFile.Header.Track;
+
+ ///
+ public ushort ArtistLength => _audioFile.Header.ArtistLength;
+
+ ///
+ public string Artist => _audioFile.Header.Artist;
+
+ ///
+ public ushort AlbumLength => _audioFile.Header.AlbumLength;
+
+ ///
+ public string Album => _audioFile.Header.Album;
+
+ ///
+ public ushort WriterLength => _audioFile.Header.WriterLength;
+
+ ///
+ public string Writer => _audioFile.Header.Writer;
+
+ ///
+ public ushort PublisherLength => _audioFile.Header.PublisherLength;
+
+ ///
+ public string Publisher => _audioFile.Header.Publisher;
+
+ ///
+ public ushort LabelLength => _audioFile.Header.LabelLength;
+
+ ///
+ public string Label => _audioFile.Header.Label;
+
+ ///
+ public ushort CommentsLength => _audioFile.Header.CommentsLength;
+
+ ///
+ public string Comments => _audioFile.Header.Comments;
+
+ #endregion
+
+ #region Unknown Block 1
+
+ ///
+ public ushort UB1_Length => _audioFile.UnknownBlock1.Length;
+
+ ///
+ public byte[] UB1_Data => _audioFile.UnknownBlock1.Data;
+
+ #endregion
+
+ #region Unknown Value 2
+
+ ///
+ public uint UnknownValue2 => _audioFile.UnknownValue2;
+
+ #endregion
+
+ #region Unknown Block 3
+
+ ///
+ public byte[] UB3_Data => _audioFile.UnknownBlock3.Data;
+
+ #endregion
+
+ #endregion
+
+ #region Instance Variables
+
+ ///
+ /// Internal representation of the archive
+ ///
+ private Models.PlayJ.AudioFile _audioFile;
+
+ #endregion
+
+ #region Constructors
+
+ ///
+ /// Private constructor
+ ///
+ private PlayJAudioFile() { }
+
+ ///
+ /// Create a PlayJ audio file from a byte array and offset
+ ///
+ /// Byte array representing the archive
+ /// Offset within the array to parse
+ /// A PlayJ audio file wrapper on success, null on failure
+ public static PlayJAudioFile 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 PlayJ audio file from a Stream
+ ///
+ /// Stream representing the archive
+ /// A PlayJ audio file wrapper on success, null on failure
+ public static PlayJAudioFile Create(Stream data)
+ {
+ // If the data is invalid
+ if (data == null || data.Length == 0 || !data.CanSeek || !data.CanRead)
+ return null;
+
+ var audioFile = Builders.PlayJ.ParseAudioFile(data);
+ if (audioFile == null)
+ return null;
+
+ var wrapper = new PlayJAudioFile
+ {
+ _audioFile = audioFile,
+ _dataSource = DataSource.Stream,
+ _streamData = data,
+ };
+ return wrapper;
+ }
+
+ #endregion
+
+ #region Printing
+
+ ///
+ public override StringBuilder PrettyPrint()
+ {
+ StringBuilder builder = new StringBuilder();
+
+ builder.AppendLine("PlayJ Audio File Information:");
+ builder.AppendLine("-------------------------");
+ builder.AppendLine();
+
+ PrintEntryHeader(builder);
+ PrintUnknownBlock1(builder);
+ PrintUnknownValue2(builder);
+ PrintUnknownBlock3(builder);
+
+ return builder;
+ }
+
+ ///
+ /// Print entry header information
+ ///
+ /// StringBuilder to append information to
+ private void PrintEntryHeader(StringBuilder builder)
+ {
+ builder.AppendLine(" Entry Header Information:");
+ builder.AppendLine(" -------------------------");
+ builder.AppendLine($" Signature: {Signature} (0x{Signature:X})");
+ builder.AppendLine($" Version: {Version} (0x{Version:X})");
+ builder.AppendLine($" Track ID: {TrackID} (0x{TrackID:X})");
+ builder.AppendLine($" Unknown offset 1: {UnknownOffset1} (0x{UnknownOffset1:X})");
+ builder.AppendLine($" Unknown offset 2: {UnknownOffset2} (0x{UnknownOffset2:X})");
+ builder.AppendLine($" Unknown offset 3: {UnknownOffset3} (0x{UnknownOffset3:X})");
+ builder.AppendLine($" Unknown 1: {Unknown1} (0x{Unknown1:X})");
+ builder.AppendLine($" Unknown 2: {Unknown2} (0x{Unknown2:X})");
+ builder.AppendLine($" Year: {Year} (0x{Year:X})");
+ builder.AppendLine($" Track number: {TrackNumber} (0x{TrackNumber:X})");
+ builder.AppendLine($" Subgenre: {Subgenre} (0x{Subgenre:X})");
+ builder.AppendLine($" Duration in seconds: {Duration} (0x{Duration:X})");
+ builder.AppendLine($" Track length: {TrackLength} (0x{TrackLength:X})");
+ builder.AppendLine($" Track: {Track ?? "[NULL]"}");
+ builder.AppendLine($" Artist length: {ArtistLength} (0x{ArtistLength:X})");
+ builder.AppendLine($" Artist: {Artist ?? "[NULL]"}");
+ builder.AppendLine($" Album length: {AlbumLength} (0x{AlbumLength:X})");
+ builder.AppendLine($" Album: {Album ?? "[NULL]"}");
+ builder.AppendLine($" Writer length: {WriterLength} (0x{WriterLength:X})");
+ builder.AppendLine($" Writer: {Writer ?? "[NULL]"}");
+ builder.AppendLine($" Publisher length: {PublisherLength} (0x{PublisherLength:X})");
+ builder.AppendLine($" Publisher: {Publisher ?? "[NULL]"}");
+ builder.AppendLine($" Label length: {LabelLength} (0x{LabelLength:X})");
+ builder.AppendLine($" Label: {Label ?? "[NULL]"}");
+ builder.AppendLine($" Comments length: {CommentsLength} (0x{CommentsLength:X})");
+ builder.AppendLine($" Comments: {Comments ?? "[NULL]"}");
+ builder.AppendLine();
+ }
+
+ ///
+ /// Print unknown block 1 information
+ ///
+ /// StringBuilder to append information to
+ private void PrintUnknownBlock1(StringBuilder builder)
+ {
+ builder.AppendLine(" Unknown Block 1 Information:");
+ builder.AppendLine(" -------------------------");
+ builder.AppendLine($" Length: {UB1_Length} (0x{UB1_Length:X})");
+ builder.AppendLine($" Data: {BitConverter.ToString(UB1_Data ?? new byte[0]).Replace('-', ' ')}");
+ builder.AppendLine();
+ }
+
+ ///
+ /// Print unknown value 2 information
+ ///
+ /// StringBuilder to append information to
+ private void PrintUnknownValue2(StringBuilder builder)
+ {
+ builder.AppendLine(" Unknown Value 2 Information:");
+ builder.AppendLine(" -------------------------");
+ builder.AppendLine($" Value: {UnknownValue2} (0x{UnknownValue2:X})");
+ builder.AppendLine();
+ }
+
+ ///
+ /// Print unknown block 3 information
+ ///
+ /// StringBuilder to append information to
+ private void PrintUnknownBlock3(StringBuilder builder)
+ {
+ builder.AppendLine(" Unknown Block 3 Information:");
+ builder.AppendLine(" -------------------------");
+ builder.AppendLine($" Data: {BitConverter.ToString(UB3_Data ?? new byte[0]).Replace('-', ' ')}");
+ builder.AppendLine();
+ }
+
+#if NET6_0_OR_GREATER
+
+ ///
+ public override string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(_audioFile, _jsonSerializerOptions);
+
+#endif
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 8b7ca96c..400aae56 100644
--- a/README.md
+++ b/README.md
@@ -178,7 +178,7 @@ Below is a list of container formats that are supported in some way:
| Nintendo CIA archive | Yes | Yes | No | |
| Nintendo DS/DSi cart image | Yes | Yes | No | |
| PKZIP and derived files (ZIP, etc.) | No | Yes | Yes | Via `SharpCompress` |
-| PlayJ audio file (PLJ) | No | Yes | No | |
+| PlayJ audio file (PLJ) | Yes* | Yes | No | Undocumented file format, many fields printed |
| Portable Executable | Yes | Yes | No* | Some packed executables are supported |
| Quantum archive (Q) | Yes | No | No | |
| RAR archive (RAR) | No | Yes | Yes | Via `SharpCompress` |
diff --git a/Test/Printer.cs b/Test/Printer.cs
index 3734482b..96280d33 100644
--- a/Test/Printer.cs
+++ b/Test/Printer.cs
@@ -195,6 +195,12 @@ namespace Test
wrapper = PAK.Create(stream);
break;
+ // PLJ
+ case SupportedFileType.PLJ:
+ wrapperName = "PlayJ audio file";
+ wrapper = PlayJAudioFile.Create(stream);
+ break;
+
// Quantum
case SupportedFileType.Quantum:
wrapperName = "Quantum archive";