GitHub Viewer
using System.Buffers.Binary;
using System.Text;
using PBPSharp.Models;
namespace PBPSharp;
///
/// Provides functionality to open and read PBP (EBOOT.PBP) files.
/// Supports single-disc and multi-disc PlayStation PBP files.
///
///
/// Missing files are reported through the returned
/// () rather than exceptions, following the CSOSharp
/// convention for the compressed-image family.
///
public sealed class PbpFile : IDisposable
{
private readonly bool _ownsStream;
private bool _disposed;
private Stream _stream;
///
/// Initializes a new instance of the class. Instances are created by
/// the static and
/// factory methods.
///
/// The seekable stream containing the PBP data.
/// Whether disposing this instance disposes .
/// The parsed PBP header.
/// The parsed PARAM.SFO metadata.
/// The disc entries discovered in the PSAR section.
private PbpFile(
Stream stream,
bool ownsStream,
PbpHeader header,
SfoData sfoData,
IReadOnlyList discs
)
{
_stream = stream;
_ownsStream = ownsStream;
Header = header;
SfoData = sfoData;
Discs = discs;
}
///
/// The parsed PBP header containing resource offsets.
///
public PbpHeader Header { get; }
///
/// The SFO (PARAM.SFO) metadata parsed from the PBP.
///
public SfoData SfoData { get; }
///
/// The list of disc entries found in the PBP.
///
public IReadOnlyList Discs { get; }
///
/// Whether this is a multi-disc PBP.
///
public bool IsMultiDisc => Discs.Count > 1;
///
/// The game title from SFO metadata.
///
public string? Title => SfoData.GetString(SfoData.Keys.Title);
///
/// The disc ID from SFO metadata (first disc).
///
public string? DiscId => SfoData.GetString(SfoData.Keys.DiscId);
///
/// The game category (e.g., "ME" for PS1 EBOOT).
///
public string? Category => SfoData.GetString(SfoData.Keys.Category);
///
/// Disposes of the PBP file and releases associated resources.
///
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_ownsStream)
_stream.Dispose();
_stream = null!;
}
///
/// Opens a PBP file from the specified file path.
///
/// The full path to the PBP file.
///
/// When this method returns, contains the opened instance if successful;
/// otherwise, null.
///
/// A indicating the result of the operation.
public static PbpError Open(string path, out PbpFile? pbp)
{
pbp = null;
if (!File.Exists(path))
return PbpError.FileNotFound;
try
{
var stream = File.OpenRead(path);
var error = Open(stream, true, out pbp);
if (error != PbpError.None)
stream.Dispose();
return error;
}
catch (IOException ex)
{
PbpDiagnostics.SetDetail($"Failed to open '{path}': {ex.Message}");
return PbpError.IoError;
}
}
///
/// Opens a PBP from an existing stream.
///
/// The stream containing PBP data. Must be seekable and readable.
/// Whether this instance should dispose the stream when disposed.
///
/// When this method returns, contains the opened instance if successful;
/// otherwise, null.
///
/// A indicating the result of the operation.
public static PbpError Open(Stream stream, bool ownsStream, out PbpFile? pbp)
{
pbp = null;
if (stream is not { CanRead: true } || !stream.CanSeek)
return PbpError.IoError;
try
{
stream.Seek(0, SeekOrigin.Begin);
var headerError = ReadHeader(stream, out var header);
if (headerError != PbpError.None)
return headerError;
var sfoError = ReadSfo(stream, header, out var sfoData);
if (sfoError != PbpError.None)
return sfoError;
var discError = ReadDiscs(stream, header, out var discs);
if (discError != PbpError.None)
return discError;
pbp = new PbpFile(stream, ownsStream, header, sfoData, discs);
return PbpError.None;
}
catch (EndOfStreamException)
{
// The stream ended before the structure it declares: the download is truncated or
// incomplete, which is a different condition from a failing device or permission.
return PbpError.TruncatedPsar;
}
catch (IOException ex)
{
PbpDiagnostics.SetDetail($"Failed to read the PBP stream: {ex.Message}");
return PbpError.IoError;
}
catch (NoIsoIndexException)
{
// The PSAR header parsed correctly but no ISO index entries followed: the file ends
// before its data area, which is the signature of a truncated or incomplete download.
return PbpError.TruncatedPsar;
}
catch (InvalidDataException)
{
return PbpError.CorruptFile;
}
catch (Exception)
{
return PbpError.CorruptFile;
}
}
///
/// Reads and validates the 40-byte PBP header at the start of the stream.
///
/// The seekable PBP stream, positioned at the start of the file.
///
/// When this method returns, contains the parsed header when the magic is valid; otherwise,
/// the default value.
///
///
/// on success; when the
/// stream is too short or the PBP magic does not match.
///
private static PbpError ReadHeader(Stream stream, out PbpHeader header)
{
header = default;
Span headerBytes = stackalloc byte[PbpHeader.HeaderSize];
try
{
stream.ReadExactly(headerBytes);
}
catch (EndOfStreamException)
{
return PbpError.InvalidHeader;
}
var magic = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[..4]);
if (magic != PbpHeader.MagicValue)
return PbpError.InvalidHeader;
var version = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[4..8]);
var sfoOffset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[8..12]);
var icon0Offset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[12..16]);
var icon1Offset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[16..20]);
var pic0Offset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[20..24]);
var pic1Offset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[24..28]);
var snd0Offset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[28..32]);
var dataPspOffset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[32..36]);
var dataPsarOffset = BinaryPrimitives.ReadInt32LittleEndian(headerBytes[36..40]);
header = new PbpHeader(
version,
sfoOffset,
icon0Offset,
icon1Offset,
pic0Offset,
pic1Offset,
snd0Offset,
dataPspOffset,
dataPsarOffset
);
return PbpError.None;
}
///
/// Reads the PARAM.SFO metadata section pointed to by the PBP header. Parsing is best
/// effort: a missing or malformed SFO leaves the returned data empty instead of failing the
/// open, because disc extraction does not depend on the metadata.
///
/// The seekable PBP stream.
/// The parsed PBP header providing the SFO offset.
///
/// When this method returns, contains the parsed SFO entries, or an empty instance when the
/// SFO is absent or corrupt.
///
/// Always .
private static PbpError ReadSfo(Stream stream, PbpHeader header, out SfoData sfoData)
{
sfoData = new SfoData();
try
{
stream.Seek(header.SfoOffset, SeekOrigin.Begin);
var sfoBuffer = new byte[4];
sfoData.Magic = ReadUInt32(stream, sfoBuffer);
sfoData.Version = ReadUInt32(stream, sfoBuffer);
// A real SFO starts with the bytes 00 50 53 46 ("\0PSF"), which as a little-endian
// uint32 is 0x46535000. A missing or corrupt SFO does not stop the run: none of the
// reference tools read the SFO when extracting disc images from the PSAR, so metadata
// is simply absent (Title/DiscId are null) rather than the whole file rejected.
if (sfoData.Magic != 0x46535000)
return PbpError.None;
sfoData.KeyTableOffset = ReadUInt32(stream, sfoBuffer);
sfoData.DataTableOffset = ReadUInt32(stream, sfoBuffer);
var entryCount = ReadUInt32(stream, sfoBuffer);
var entries = new List();
var dataTableSize = 0UL;
for (var i = 0; i < entryCount; i++)
{
var dirBuffer = new byte[16];
stream.Seek(header.SfoOffset + 20 + i * 16, SeekOrigin.Begin);
stream.ReadExactly(dirBuffer, 0, 16);
// Layout: KeyOffset(2) + Format(2) + Length(4) + MaxLength(4) + DataOffset(4)
var keyOffset = BinaryPrimitives.ReadUInt16LittleEndian(dirBuffer.AsSpan(0, 2));
var entry = new SfoEntry
{
Format = BinaryPrimitives.ReadUInt16LittleEndian(dirBuffer.AsSpan(2, 2)),
Length = BinaryPrimitives.ReadUInt32LittleEndian(dirBuffer.AsSpan(4, 4)),
MaxLength = BinaryPrimitives.ReadUInt32LittleEndian(dirBuffer.AsSpan(8, 4))
};
var dataOffset = BinaryPrimitives.ReadUInt32LittleEndian(dirBuffer.AsSpan(12, 4));
var entryEnd = (ulong)dataOffset + entry.MaxLength;
if (entryEnd > dataTableSize)
dataTableSize = entryEnd;
stream.Seek(header.SfoOffset + sfoData.KeyTableOffset + keyOffset, SeekOrigin.Begin);
entry.Key = ReadNullTerminatedString(stream, 128);
stream.Seek(header.SfoOffset + sfoData.DataTableOffset + dataOffset, SeekOrigin.Begin);
switch (entry.Format)
{
case 0x0204:
// The declared length is untrusted; it cannot exceed what is left in the
// file, and must fit an int so a corrupt entry cannot drive a multi-gigabyte
// allocation or overflow the cast below.
var remaining = stream.Length - stream.Position;
if (remaining > 0 && entry.Length