diff --git a/src/BizHawk.Client.Common/RomLoader.cs b/src/BizHawk.Client.Common/RomLoader.cs index 2506868be8..ee7a152d28 100644 --- a/src/BizHawk.Client.Common/RomLoader.cs +++ b/src/BizHawk.Client.Common/RomLoader.cs @@ -898,7 +898,7 @@ namespace BizHawk.Client.Common /// TODO add and handle (you can drag-and-drop scripts and there are already non-rom things in this list, so why not?) public static readonly FilesystemFilterSet RomFilter = new( new FilesystemFilter("Music Files", Array.Empty(), devBuildExtraExts: new[] { "psf", "minipsf", "sid", "nsf", "gbs" }), - new FilesystemFilter("Disc Images", new[] { "cue", "ccd", "mds", "m3u" }), + new FilesystemFilter("Disc Images", new[] { "cue", "ccd", "cdi", "mds", "m3u" }), new FilesystemFilter("NES", RomFileExtensions.NES.Concat(new[] { "nsf" }).ToList(), addArchiveExts: true), new FilesystemFilter("Super NES", RomFileExtensions.SNES, addArchiveExts: true), new FilesystemFilter("PlayStation", new[] { "cue", "ccd", "mds", "m3u" }), diff --git a/src/BizHawk.Client.EmuHawk/MainForm.cs b/src/BizHawk.Client.EmuHawk/MainForm.cs index c9f27ddf96..92e6dc5b30 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.cs @@ -3880,7 +3880,7 @@ namespace BizHawk.Client.EmuHawk var ext = Path.GetExtension(xmlGame.AssetFullPaths[xg])?.ToLowerInvariant(); var (filename, data) = xmlGame.Assets[xg]; - if (ext == ".cue" || ext == ".ccd" || ext == ".toc" || ext == ".mds") + if (ext is ".cue" or ".ccd" or ".cdi" or ".toc" or ".mds") { xSw.WriteLine(Path.GetFileNameWithoutExtension(filename)); xSw.WriteLine("SHA1:N/A"); diff --git a/src/BizHawk.Emulation.DiscSystem/Disc.cs b/src/BizHawk.Emulation.DiscSystem/Disc.cs index 328a2a3857..f4a14cc6eb 100644 --- a/src/BizHawk.Emulation.DiscSystem/Disc.cs +++ b/src/BizHawk.Emulation.DiscSystem/Disc.cs @@ -122,6 +122,6 @@ namespace BizHawk.Emulation.DiscSystem {} public static bool IsValidExtension(string extension) - => extension.ToLowerInvariant() is ".ccd" or ".cue" or ".iso" or ".mds"; + => extension.ToLowerInvariant() is ".ccd" or ".cdi" or ".cue" or ".iso" or ".mds"; } } \ No newline at end of file diff --git a/src/BizHawk.Emulation.DiscSystem/DiscFormats/CDI_format.cs b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CDI_format.cs new file mode 100644 index 0000000000..1deb039471 --- /dev/null +++ b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CDI_format.cs @@ -0,0 +1,599 @@ +using System; +using System.IO; +using System.Globalization; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using BizHawk.Common; +using BizHawk.Common.IOExtensions; +using BizHawk.Emulation.DiscSystem.CUE; + +//DiscJuggler CDI images +//https://problemkaputt.de/psxspx-cdrom-disk-images-cdi-discjuggler.htm +//https://github.com/cdemu/cdemu/blob/1f90f74/libmirage/images/image-cdi/parser.c + +namespace BizHawk.Emulation.DiscSystem +{ + public class CDI_Format + { + /// + /// Represents a CDI file, faithfully. Minimal interpretation of the data happens. + /// + public class CDIFile + { + /// + /// Number of sessions + /// + public byte NumSessions; + + /// + /// The session blocks + /// + public readonly IList Sessions = new List(); + + /// + /// The track blocks + /// + public readonly IList Tracks = new List(); + + /// + /// The disc info block + /// + public readonly CDIDiscInfo DiscInfo = new(); + + /// + /// Footer size in bytes + /// + public uint Entrypoint; + } + + /// + /// Represents a session block from a CDI file + /// + public class CDISession + { + /// + /// Number of tracks in session (1..99) (or 0 = no more sessions) + /// + public byte NumTracks; + } + + /// + /// Represents a track/disc info block header from a CDI track + /// + public class CDITrackHeader + { + /// + /// Number of tracks on disc (1..99) + /// + public byte NumTracks; + + /// + /// Full Path/Filename (may be empty) + /// + public string Path; + + /// + /// 0x0098 = CD-ROM, 0x0038 = DVD-ROM + /// + public ushort MediumType; + } + + /// + /// Represents a CD text block from a CDI track + /// + public class CDICDText + { + /// + /// A CD text block has 0-18 strings, each of variable length + /// + public readonly IList CdTexts = new List(); + } + + /// + /// Represents a track block from a CDI file + /// + public class CDITrack : CDITrackHeader + { + /// + /// The sector count of each index specified for the track + /// + public readonly IList IndexSectorCounts = new List(); + + /// + /// CD text blocks + /// + public readonly IList CdTextBlocks = new List(); + + /// + /// The specified track mode (0 = Audio, 1 = Mode1, 2 = Mode2/Mixed) + /// + public byte TrackMode; + + /// + /// Session number (0-indexed) + /// + public uint SessionNumber; + + /// + /// Track number (0-indexed, releative to session) + /// + public uint TrackNumber; + + /// + /// Track start address + /// + public uint TrackStartAddress; + + /// + /// Track length, in sectors + /// + public uint TrackLength; + + /// + /// The specified read mode (0 = Mode1, 1 = Mode2, 2 = Audio, 3 = Raw+Q, 4 = Raw+PQRSTUVW) + /// + public uint ReadMode; + + /// + /// Upper 4 bits of ADR/Control + /// + public uint Control; + + /// + /// 12-letter/digit string (may be empty) + /// + public string IsrcCode; + + /// + /// Any non-zero is valid? + /// + public uint IsrcValidFlag; + + /// + /// Only present on last track of a session (0 = Audio/CD-DA, 1 = Mode1/CD-ROM, 2 = Mode2/CD-XA) + /// + public uint SessionType; + } + + /// + /// Represents a disc info block from a CDI file + /// + public class CDIDiscInfo : CDITrackHeader + { + /// + /// Total number of sectors + /// + public uint DiscSize; + + /// + /// probably junk for non-ISO data discs + /// + public string VolumeId; + + /// + /// 13-digit string (may be empty) + /// + public string Ean13Code; + + /// + /// Any non-zero is valid? + /// + public uint Ean13CodeValid; + + /// + /// CD text (for lead-in?) + /// + public string CdText; + } + + public class CDIParseException : Exception + { + public CDIParseException(string message) : base(message) { } + } + + /// malformed cdi format + public static CDIFile ParseFrom(Stream stream) + { + var cdif = new CDIFile(); + using var br = new BinaryReader(stream); + + try + { + stream.Seek(-4, SeekOrigin.End); + cdif.Entrypoint = br.ReadUInt32(); + + stream.Seek(-cdif.Entrypoint, SeekOrigin.End); + + cdif.NumSessions = br.ReadByte(); + if (cdif.NumSessions == 0) + { + throw new CDIParseException("Malformed CDI format: 0 sessions!"); + } + + void ParseTrackHeader(CDITrackHeader header) + { + stream.Seek(15, SeekOrigin.Current); // unknown bytes + header.NumTracks = br.ReadByte(); + var pathLen = br.ReadByte(); + header.Path = br.ReadStringFixedUtf8(pathLen); + stream.Seek(29, SeekOrigin.Current); // unknown bytes + header.MediumType = br.ReadUInt16(); + switch (header.MediumType) + { + case 0x0038: + throw new CDIParseException("Malformed CDI format: DVD was specified, but this is not supported!"); + case 0x0098: + return; + default: + throw new CDIParseException("Malformed CDI format: Invalid medium type!"); + } + } + + for (var i = 0; i <= cdif.NumSessions; i++) + { + var session = new CDISession(); + stream.Seek(1, SeekOrigin.Current); // unknown byte + session.NumTracks = br.ReadByte(); + stream.Seek(13, SeekOrigin.Current); // unknown bytes + cdif.Sessions.Add(session); + + // the last session block should have 0 tracks (as it indicates no more sessions) + if (session.NumTracks == 0 && i != cdif.NumSessions) + { + throw new CDIParseException("Malformed CDI format: No tracks in session!"); + } + + if (session.NumTracks + cdif.Tracks.Count > 99) + { + throw new CDIParseException("Malformed CDI format: More than 99 tracks on disc!"); + } + + for (var j = 0; j < session.NumTracks; j++) + { + var track = new CDITrack(); + ParseTrackHeader(track); + + var indexes = br.ReadUInt16(); + if (indexes < 2) // We should have at least 2 indexes (one pre-gap, and one "real" one) + { + throw new CDIParseException("Malformed CDI format: Less than 2 indexes in track!"); + } + for (var k = 0; k < indexes; k++) + { + track.IndexSectorCounts.Add(br.ReadUInt32()); + } + + var numCdTextBlocks = br.ReadUInt32(); + for (var k = 0; k < numCdTextBlocks; k++) + { + var cdTextBlock = new CDICDText(); + for (var l = 0; l < 18; l++) + { + var cdTextLen = br.ReadByte(); + if (cdTextLen > 0) + { + cdTextBlock.CdTexts.Add(br.ReadStringFixedUtf8(cdTextLen)); + } + } + track.CdTextBlocks.Add(cdTextBlock); + } + + stream.Seek(2, SeekOrigin.Current); // unknown bytes + track.TrackMode = br.ReadByte(); + if (track.TrackMode > 2) + { + throw new CDIParseException("Malformed CDI format: Invalid track mode!"); + } + + stream.Seek(7, SeekOrigin.Current); // unknown bytes + track.SessionNumber = br.ReadUInt32(); + if (track.SessionNumber != i) + { + throw new CDIParseException("Malformed CDI format: Session number mismatch!"); + } + + track.TrackNumber = br.ReadUInt32(); + if (track.TrackNumber != j) // I think this is relative to the session? + { + throw new CDIParseException("Malformed CDI format: Track number mismatch!"); + } + + track.TrackStartAddress = br.ReadUInt32(); + track.TrackLength = br.ReadUInt32(); + stream.Seek(16, SeekOrigin.Current); // unknown bytes + + track.ReadMode = br.ReadUInt32(); + if (track.ReadMode > 4) + { + throw new CDIParseException("Malformed CDI format: Invalid read mode!"); + } + + track.Control = br.ReadUInt32(); + if ((track.Control & ~0xF) != 0) + { + throw new CDIParseException("Malformed CDI format: Invalid control!"); + } + + stream.Seek(1, SeekOrigin.Current); // unknown byte + var redundantTrackLen = br.ReadUInt32(); + if (track.TrackLength != redundantTrackLen) + { + throw new CDIParseException("Malformed CDI format: Track length mismatch!"); + } + + stream.Seek(4, SeekOrigin.Current); // unknown bytes + track.IsrcCode = br.ReadStringFixedUtf8(12); + track.IsrcValidFlag = br.ReadUInt32(); + if (track.IsrcValidFlag == 0) + { + track.IsrcCode = string.Empty; + } + + stream.Seek(87, SeekOrigin.Current); // unknown bytes + track.SessionType = br.ReadByte(); + switch (track.SessionType) + { + case > 2: + throw new CDIParseException("Malformed CDI format: Invalid session type!"); + case > 0 when j != session.NumTracks - 1: + throw new CDIParseException("Malformed CDI format: Session type was specified, but this is only supposed to be present on the last track!"); + } + + stream.Seek(5, SeekOrigin.Current); // unknown bytes + var notLastTrackInSession = br.ReadByte(); + switch (notLastTrackInSession) + { + case 0 when j != session.NumTracks - 1: + throw new CDIParseException("Malformed CDI format: Track was specified to be the last track of the session, but more tracks are available!"); + case > 1: + throw new CDIParseException("Malformed CDI format: Invalid not last track of session flag!"); + } + + stream.Seek(5, SeekOrigin.Current); // unknown bytes + // well, the last 4 bytes here are said to be the "address for last track of a session? (otherwise 00,00,FF,FF)" + // except I'm not sure what the address is meant to be, by bytes? by sectors? relative to file? relative session start? + // for now I just ignore it + + cdif.Tracks.Add(track); + } + } + + ParseTrackHeader(cdif.DiscInfo); + cdif.DiscInfo.DiscSize = br.ReadUInt32(); + if (cdif.DiscInfo.DiscSize != cdif.Tracks.Sum(t => t.TrackLength)) + { + //throw new CDIParseException("Malformed CDI format: Disc size mismatch!"); + //this seems to be wrong? + } + + var volumeIdLen = br.ReadByte(); + cdif.DiscInfo.VolumeId = br.ReadStringFixedUtf8(volumeIdLen); + stream.Seek(9, SeekOrigin.Current); // unknown bytes + + cdif.DiscInfo.Ean13Code = br.ReadStringFixedUtf8(13); + cdif.DiscInfo.Ean13CodeValid = br.ReadUInt32(); + if (cdif.DiscInfo.Ean13CodeValid == 0) + { + cdif.DiscInfo.Ean13Code = string.Empty; + } + + var cdTextLengh = br.ReadUInt32(); + if (cdTextLengh > int.MaxValue) + { + // suppose technically this might not be considered too large purely going off the format + // but it's a bit silly to have a >2GB string so this is probably not valid + throw new CDIParseException("Malformed CDI format: CD text too large!"); + } + + cdif.DiscInfo.CdText = br.ReadStringFixedUtf8((int)cdTextLengh); + stream.Seek(12, SeekOrigin.Current); // unknown bytes + + if (cdif.Tracks.Any(track => track.NumTracks != cdif.Tracks.Count) || cdif.DiscInfo.NumTracks != cdif.Tracks.Count) + { + throw new CDIParseException("Malformed CDI format: Total track number mismatch!"); + } + + if (stream.Position != stream.Length - 4) + { + throw new CDIParseException("Malformed CDI format: Did not reach end of footer after parsing!"); + } + + return cdif; + } + catch (EndOfStreamException) + { + throw new CDIParseException("Malformed CDI format: Unexpected stream end!"); + } + } + + public class LoadResults + { + public CDIFile ParsedCDIFile; + public bool Valid; + public CDIParseException FailureException; + public string CdiPath; + } + + public static LoadResults LoadCDIPath(string path) + { + var ret = new LoadResults + { + CdiPath = path + }; + try + { + if (!File.Exists(path)) throw new CDIParseException("Malformed CDI format: nonexistent CDI file!"); + + CDIFile cdif; + using (var infCDI = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + cdif = ParseFrom(infCDI); + + ret.ParsedCDIFile = cdif; + ret.Valid = true; + } + catch (CDIParseException ex) + { + ret.FailureException = ex; + } + + return ret; + } + + private class SS_CDI_RawQ : SS_Base + { + public override void Synth(SectorSynthJob job) + { + Blob.Read(BlobOffset, job.DestBuffer2448, job.DestOffset, 2352); + + if ((job.Parts & ESectorSynthPart.SubchannelP) != 0) + { + SynthUtils.SubP(job.DestBuffer2448, job.DestOffset + 2352, Pause); + } + + // Q is present in the blob and non-interleaved + if ((job.Parts & ESectorSynthPart.SubchannelQ) != 0) + { + Blob.Read(BlobOffset, job.DestBuffer2448, job.DestOffset + 2352 + 12, 12); + } + + //clear R-W if needed + if ((job.Parts & ESectorSynthPart.Subchannel_RSTUVW) != 0) + { + Array.Clear(job.DestBuffer2448, job.DestOffset + 2352 + 12 + 12, 12 * 6); + } + + //subcode has been generated deinterleaved; we may still need to interleave it + if ((job.Parts & ESectorSynthPart.SubcodeAny) != 0 && (job.Parts & ESectorSynthPart.SubcodeDeinterleave) == 0) + { + SynthUtils.InterleaveSubcodeInplace(job.DestBuffer2448, job.DestOffset + 2352); + } + } + } + + private class SS_CDI_RawPQRSTUVW : SS_Base + { + public override void Synth(SectorSynthJob job) + { + // all subcode is present and interleaved, just read it all + Blob.Read(BlobOffset, job.DestBuffer2448, job.DestOffset, 2448); + + // deinterleave it if needed + if ((job.Parts & ESectorSynthPart.SubcodeDeinterleave) != 0) + { + SynthUtils.DeinterleaveSubcodeInplace(job.DestBuffer2448, job.DestOffset + 2352); + } + } + } + + /// file not found + public static Disc LoadCDIToDisc(string cdiPath, DiscMountPolicy IN_DiscMountPolicy) + { + var loadResults = LoadCDIPath(cdiPath); + if (!loadResults.Valid) + throw loadResults.FailureException; + + var disc = new Disc(); + var cdif = loadResults.ParsedCDIFile; + + IBlob cdiBlob = new Blob_RawFile { PhysicalPath = cdiPath }; + disc.DisposableResources.Add(cdiBlob); + + var trackOffset = 0; + var blobOffset = 0; + for (var i = 0; i < cdif.NumSessions; i++) + { + var session = new DiscSession { Number = i + 1 }; + for (var j = 0; j < cdif.Sessions[i].NumTracks; j++) + { + var track = cdif.Tracks[trackOffset + j]; + + RawTOCEntry EmitRawTOCEntry() + { + var q = default(SubchannelQ); + //absent some kind of policy for how to set it, this is a safe assumption + const byte kADR = 1; + q.SetStatus(kADR, (EControlQ)track.Control); + q.q_tno = BCD2.FromDecimal(0); + q.q_index = BCD2.FromDecimal(trackOffset + j + 1); + q.Timestamp = 0; + q.zero = 0; + q.AP_Timestamp = disc._Sectors.Count; + q.q_crc = 0; + return new() { QData = q }; + } + + var sectorSize = track.ReadMode switch + { + 0 => 2048, + 1 => 2336, + 2 => 2352, + 3 => 2368, + 4 => 2448, + _ => throw new InvalidOperationException() + }; + var curIndex = 0; + var relMSF = -track.IndexSectorCounts[0]; + var indexSectorOffset = 0U; + for (var k = 0; k < track.TrackLength; k++) + { + if (track.IndexSectorCounts[curIndex] == k - indexSectorOffset) + { + indexSectorOffset += track.IndexSectorCounts[curIndex]; + curIndex++; + if (track.IndexSectorCounts.Count == curIndex) + { + throw new CDIParseException("Malformed CDI Format: Reached end of index list unexpectedly"); + } + if (curIndex == 1) + { + session.RawTOCEntries.Add(EmitRawTOCEntry()); + } + } + //note that CDIs contain the pregap data themselves... + SS_Base synth = track.ReadMode switch + { + 0 => new SS_Mode1_2048(), + 1 => throw new NotSupportedException("Mode2/2336"), // TODO + 2 => new SS_2352(), + 3 => new SS_CDI_RawQ(), + 4 => new SS_CDI_RawPQRSTUVW(), + _ => throw new InvalidOperationException() + }; + synth.Blob = cdiBlob; + synth.BlobOffset = blobOffset; + synth.Policy = IN_DiscMountPolicy; + //TODO: subchannel here is all wrong for gaps, probably + const byte kADR = 1; + synth.sq.SetStatus(kADR, (EControlQ)track.Control); + synth.sq.q_tno = BCD2.FromDecimal(trackOffset + j + 1); + synth.sq.q_index = BCD2.FromDecimal(curIndex); + synth.sq.Timestamp = (int)relMSF; + synth.sq.zero = 0; + synth.sq.AP_Timestamp = disc._Sectors.Count; + synth.sq.q_crc = 0; + synth.Pause = curIndex == 0; + disc._Sectors.Add(synth); + blobOffset += sectorSize; + if (curIndex != 0) + { + relMSF++; + } + } + } + + var TOCMiscInfo = new Synthesize_A0A1A2_Job( + firstRecordedTrackNumber: trackOffset + 1, + lastRecordedTrackNumber: trackOffset + cdif.Sessions[i].NumTracks + 1, + sessionFormat: (SessionFormat)(cdif.Tracks[trackOffset + cdif.Sessions[i].NumTracks - 1].SessionType * 0x10), + leadoutTimestamp: disc._Sectors.Count); + TOCMiscInfo.Run(session.RawTOCEntries); + + disc.Sessions.Add(session); + trackOffset += cdif.Sessions[i].NumTracks; + } + + return disc; + } + } +} \ No newline at end of file diff --git a/src/BizHawk.Emulation.DiscSystem/DiscIdentifier.cs b/src/BizHawk.Emulation.DiscSystem/DiscIdentifier.cs index bbd74d7063..60be54f3b4 100644 --- a/src/BizHawk.Emulation.DiscSystem/DiscIdentifier.cs +++ b/src/BizHawk.Emulation.DiscSystem/DiscIdentifier.cs @@ -403,9 +403,15 @@ namespace BizHawk.Emulation.DiscSystem if (_disc.Sessions.Count > 2 && !_disc.Sessions[2].TOC.TOCItems[_disc.Sessions[2].TOC.FirstRecordedTrackNumber].IsData) { var data = new byte[2352]; - _dsr.ReadLBA_2352(_disc.Sessions[2].Tracks[1].LBA, data, 0); - var s = Encoding.ASCII.GetString(data); - return s.Contains("ATARI APPROVED DATA HEADER ATRI") || s.Contains("TARA IPARPVODED TA AEHDAREA RT"); + for (var i = 0; i < 2; i++) + { + _dsr.ReadLBA_2352(_disc.Sessions[2].Tracks[1].LBA + i, data, 0); + var s = Encoding.ASCII.GetString(data); + if (s.Contains("ATARI APPROVED DATA HEADER ATRI") || s.Contains("TARA IPARPVODED TA AEHDAREA RT")) + { + return true; + } + } } return false; diff --git a/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs b/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs index 2b2e7ec383..5c1b371609 100644 --- a/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs +++ b/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs @@ -193,6 +193,9 @@ namespace BizHawk.Emulation.DiscSystem case ".ccd": OUT_Disc = CCD_Format.LoadCCDToDisc(IN_FromPath, IN_DiscMountPolicy); break; + case ".cdi": + OUT_Disc = CDI_Format.LoadCDIToDisc(IN_FromPath, IN_DiscMountPolicy); + break; case ".cue": LoadCue(dir, File.ReadAllText(IN_FromPath)); break;