diff --git a/.gitmodules b/.gitmodules index 6687ec1553..44d82333b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -77,3 +77,6 @@ path = waterbox/gpgx/Genesis-Plus-GX url = https://github.com/TASEmulators/Genesis-Plus-GX.git branch = tasvideos-2.1 +[submodule "ExternalProjects/libchdr/libchdr"] + path = ExternalProjects/libchdr/libchdr + url = https://github.com/rtissera/libchdr.git diff --git a/Assets/dll/chdr.dll b/Assets/dll/chdr.dll new file mode 100644 index 0000000000..88b1449b68 Binary files /dev/null and b/Assets/dll/chdr.dll differ diff --git a/ExternalProjects/libchdr/.gitignore b/ExternalProjects/libchdr/.gitignore new file mode 100644 index 0000000000..3722ac63ca --- /dev/null +++ b/ExternalProjects/libchdr/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/ExternalProjects/libchdr/build_release.bat b/ExternalProjects/libchdr/build_release.bat new file mode 100644 index 0000000000..06625b160a --- /dev/null +++ b/ExternalProjects/libchdr/build_release.bat @@ -0,0 +1,10 @@ +rmdir /s /q build +mkdir build +cd build +:: cl must be used as clang fails to compile :( +cmake ..\libchdr -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=cl -G Ninja ^ + -DBUILD_LTO=ON -DBUILD_SHARED_LIBS=ON -DINSTALL_STATIC_LIBS=OFF -DWITH_SYSTEM_ZLIB=OFF +ninja +xcopy .\chdr.dll ..\..\..\Assets\dll\ /Y +xcopy .\chdr.dll ..\..\..\output\dll\ /Y +cd .. diff --git a/ExternalProjects/libchdr/build_release.sh b/ExternalProjects/libchdr/build_release.sh new file mode 100755 index 0000000000..a10465fe4c --- /dev/null +++ b/ExternalProjects/libchdr/build_release.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e +if [ -z "$CC" ]; then export CC="clang"; fi + +rm -rf build +mkdir build +cd build +cmake ../libchdr -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=$CC -G Ninja \ + -DBUILD_LTO=ON -DBUILD_SHARED_LIBS=ON -DINSTALL_STATIC_LIBS=OFF -DWITH_SYSTEM_ZLIB=OFF +ninja +cp -t ../../../Assets/dll/ ./libchdr.so +cp -t ../../../output/dll/ ./libchdr.so diff --git a/ExternalProjects/libchdr/libchdr b/ExternalProjects/libchdr/libchdr new file mode 160000 index 0000000000..5c598c2df3 --- /dev/null +++ b/ExternalProjects/libchdr/libchdr @@ -0,0 +1 @@ +Subproject commit 5c598c2df3a7717552a76410d79f5af01ff51b1d diff --git a/src/BizHawk.Client.Common/RomLoader.cs b/src/BizHawk.Client.Common/RomLoader.cs index d4411bc1bf..009f11ceb1 100644 --- a/src/BizHawk.Client.Common/RomLoader.cs +++ b/src/BizHawk.Client.Common/RomLoader.cs @@ -557,6 +557,20 @@ namespace BizHawk.Client.Common game = rom.GameInfo; } + // HACK due to MAME wanting CHDs as hard drives / handling it on its own (bad design, I know!) + // only matters for XML, as CHDs are never the "main" rom for MAME + // (in general, this is kind of bad as CHD hard drives might be useful for other future cores?) + private static bool IsDiscForXML(string system, string path) + { + var ext = Path.GetExtension(path); + if (system == VSystemID.Raw.Arcade && ext.ToLowerInvariant() == ".chd") + { + return false; + } + + return Disc.IsValidExtension(ext); + } + private bool LoadXML(string path, CoreComm nextComm, HawkFile file, string forcedCoreName, out IEmulator nextEmulator, out RomGame rom, out GameInfo game) { nextEmulator = null; @@ -573,7 +587,7 @@ namespace BizHawk.Client.Common Comm = nextComm, Game = game, Roms = xmlGame.Assets - .Where(kvp => !Disc.IsValidExtension(Path.GetExtension(kvp.Key))) + .Where(kvp => !IsDiscForXML(system, kvp.Key)) .Select(kvp => (IRomAsset)new RomAsset { RomData = kvp.Value, @@ -584,7 +598,7 @@ namespace BizHawk.Client.Common }) .ToList(), Discs = xmlGame.AssetFullPaths - .Where(p => Disc.IsValidExtension(Path.GetExtension(p))) + .Where(p => IsDiscForXML(system, p)) .Select(discPath => (p: discPath, d: DiscExtensions.CreateAnyType(discPath, str => DoLoadErrorCallback(str, system, LoadErrorType.DiscError)))) .Where(a => a.d != null) .Select(a => (IDiscAsset)new DiscAsset diff --git a/src/BizHawk.Client.DiscoHawk/MainDiscoForm.Designer.cs b/src/BizHawk.Client.DiscoHawk/MainDiscoForm.Designer.cs index 66f924c1fe..02a5c8e994 100644 --- a/src/BizHawk.Client.DiscoHawk/MainDiscoForm.Designer.cs +++ b/src/BizHawk.Client.DiscoHawk/MainDiscoForm.Designer.cs @@ -154,7 +154,7 @@ this.label3.Name = "label3"; this.label3.Size = new System.Drawing.Size(235, 33); this.label3.TabIndex = 7; - this.label3.Text = "- Uses FFMPEG for audio decoding\r\n- Loads ISO, CUE, CCD, CDI, MDS, and NRG"; + this.label3.Text = "- Uses FFMPEG for audio decoding\r\n- Loads ISO, CUE, CCD, CDI, CHD, MDS, and NRG"; // // radioButton2 // diff --git a/src/BizHawk.Emulation.DiscSystem/Disc.cs b/src/BizHawk.Emulation.DiscSystem/Disc.cs index 1fbd3b9b30..5ce69c5e7f 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 ".cdi" or ".cue" or ".iso" or ".toc" or ".mds" or ".nrg"; + => extension.ToLowerInvariant() is ".ccd" or ".cdi" or ".chd" or ".cue" or ".iso" or ".toc" or ".mds" or ".nrg"; } } \ No newline at end of file diff --git a/src/BizHawk.Emulation.DiscSystem/DiscFormats/Blobs/Blob_CHD.cs b/src/BizHawk.Emulation.DiscSystem/DiscFormats/Blobs/Blob_CHD.cs new file mode 100644 index 0000000000..70232deb68 --- /dev/null +++ b/src/BizHawk.Emulation.DiscSystem/DiscFormats/Blobs/Blob_CHD.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; + +namespace BizHawk.Emulation.DiscSystem +{ + internal class Blob_CHD : IBlob + { + private LibChdr.CoreFileStreamWrapper _coreFile; + private IntPtr _chdFile; + + private readonly uint _hunkSize; + private readonly byte[] _hunkCache; + private int _currentHunk; + + public Blob_CHD(LibChdr.CoreFileStreamWrapper coreFile, IntPtr chdFile, uint hunkSize) + { + _coreFile = coreFile; + _chdFile = chdFile; + _hunkSize = hunkSize; + _hunkCache = new byte[hunkSize]; + _currentHunk = -1; + } + + public void Dispose() + { + if (_chdFile != IntPtr.Zero) + { + LibChdr.chd_close(_chdFile); + _chdFile = IntPtr.Zero; + } + + _coreFile?.Dispose(); + _coreFile = null; + } + + public int Read(long byte_pos, byte[] buffer, int offset, int count) + { + var ret = count; + while (count > 0) + { + var targetHunk = (uint)(byte_pos / _hunkSize); + if (targetHunk != _currentHunk) + { + var err = LibChdr.chd_read(_chdFile, targetHunk, _hunkCache); + if (err != LibChdr.chd_error.CHDERR_NONE) + { + // shouldn't ever happen in practice, unless something has gone terribly wrong + throw new IOException($"CHD read failed with error {err}"); + } + + _currentHunk = (int)targetHunk; + } + + var hunkOffset = (uint)(byte_pos - targetHunk * _hunkSize); + var bytesToCopy = Math.Min((int)(_hunkSize - hunkOffset), count); + Buffer.BlockCopy(_hunkCache, (int)hunkOffset, buffer, offset, bytesToCopy); + offset += bytesToCopy; + count -= bytesToCopy; + } + + return ret; + } + } +} diff --git a/src/BizHawk.Emulation.DiscSystem/DiscFormats/CHD_format.cs b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CHD_format.cs new file mode 100644 index 0000000000..4bb2daab4a --- /dev/null +++ b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CHD_format.cs @@ -0,0 +1,793 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using BizHawk.Common; +using BizHawk.Emulation.DiscSystem.CUE; + +#pragma warning disable BHI1005 + +//MAME CHD images, using the standard libchdr for reading + +namespace BizHawk.Emulation.DiscSystem +{ + public static class CHD_Format + { + /// + /// Represents a CHD file. + /// This isn't particularly faithful to the format, but rather it just wraps libchdr's chd_file + /// + public class CHDFile + { + /// + /// Wrapper of a C# stream to a chd_core_file + /// + public LibChdr.CoreFileStreamWrapper CoreFile; + + /// + /// chd_file* to be used for libchdr functions + /// + public IntPtr ChdFile; + + /// + /// CHD header, interpreted by libchdr + /// + public LibChdr.chd_header Header; + + /// + /// CHD CD metadata for each track + /// + public readonly IList CdMetadatas = new List(); + } + + /// + /// Results of chd_get_metadata with cdrom track metadata tags + /// + public class CHDCdMetadata + { + /// + /// Track number (1..99) + /// + public uint Track; + + /// + /// Indicates this is a CDI format + /// chd_track_type doesn't have an explicit enum for this + /// However, this is still important info for discerning the session format + /// + public bool IsCDI; + + /// + /// Track type + /// + public LibChdr.chd_track_type TrackType; + + /// + /// Subcode type + /// + public LibChdr.chd_sub_type SubType; + + /// + /// Size of each sector + /// + public uint SectorSize; + + /// + /// Subchannel size + /// + public uint SubSize; + + /// + /// Number of frames in this track + /// This might include pregap, if that is stored in the chd + /// + public uint Frames; + + /// + /// Number of "padding" frames in this track + /// This is done in order to maintain a multiple of 4 frames for each track + /// These padding frames aren't representative of the actual disc anyways + /// They're only useful to know the offset of the next track within the chd + /// + public uint Padding; + + /// + /// Number of pregap sectors + /// + public uint Pregap; + + /// + /// Pregap track type + /// + public LibChdr.chd_track_type PregapTrackType; + + /// + /// Pregap subcode type + /// + public LibChdr.chd_sub_type PregapSubType; + + /// + /// Indicates whether pregap is in the CHD + /// If pregap isn't in the CHD, it needs to be generated where appropriate + /// + public bool PregapInChd; + + /// + /// Number of postgap sectors + /// + public uint PostGap; + } + + public class CHDParseException : Exception + { + public CHDParseException(string message) : base(message) { } + public CHDParseException(string message, Exception ex) : base(message, ex) { } + } + + private static LibChdr.chd_track_type GetTrackType(string type) + { + return type switch + { + "MODE1" => LibChdr.chd_track_type.CD_TRACK_MODE1, + "MODE1/2048" => LibChdr.chd_track_type.CD_TRACK_MODE1, + "MODE1_RAW" => LibChdr.chd_track_type.CD_TRACK_MODE1_RAW, + "MODE1/2352" => LibChdr.chd_track_type.CD_TRACK_MODE1_RAW, + "MODE2" => LibChdr.chd_track_type.CD_TRACK_MODE2, + "MODE2/2336" => LibChdr.chd_track_type.CD_TRACK_MODE2, + "MODE2_FORM1" => LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1, + "MODE2/2048" => LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1, + "MODE2_FORM2" => LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2, + "MODE2/2324" => LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2, + "MODE2_FORM_MIX" => LibChdr.chd_track_type.CD_TRACK_MODE2_FORM_MIX, + "MODE2_RAW" => LibChdr.chd_track_type.CD_TRACK_MODE2_RAW, + "MODE2/2352" => LibChdr.chd_track_type.CD_TRACK_MODE2_RAW, + "CDI/2352" => LibChdr.chd_track_type.CD_TRACK_MODE2_RAW, + "AUDIO" => LibChdr.chd_track_type.CD_TRACK_AUDIO, + _ => throw new CHDParseException("Malformed CHD format: Invalid track type!"), + }; + } + + private static (LibChdr.chd_track_type TrackType, bool ChdContainsPregap) GetTrackTypeForPregap(string type) + { + if (type.Length > 0 && type[0] == 'V') + { + return (GetTrackType(type[1..]), true); + } + + return (GetTrackType(type), false); + } + + private static uint GetSectorSize(LibChdr.chd_track_type type) + { + return type switch + { + LibChdr.chd_track_type.CD_TRACK_MODE1 => 2048, + LibChdr.chd_track_type.CD_TRACK_MODE1_RAW => 2352, + LibChdr.chd_track_type.CD_TRACK_MODE2 => 2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1 => 2048, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2 => 2324, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM_MIX => 2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_RAW => 2352, + LibChdr.chd_track_type.CD_TRACK_AUDIO => 2352, + _ => throw new CHDParseException("Malformed CHD format: Invalid track type!"), + }; + } + + private static LibChdr.chd_sub_type GetSubType(string type) + { + return type switch + { + "RW" => LibChdr.chd_sub_type.CD_SUB_NORMAL, + "RW_RAW" => LibChdr.chd_sub_type.CD_SUB_RAW, + "NONE" => LibChdr.chd_sub_type.CD_SUB_NONE, + _ => throw new CHDParseException("Malformed CHD format: Invalid sub type!"), + }; + } + + private static uint GetSubSize(LibChdr.chd_sub_type type) + { + return type switch + { + LibChdr.chd_sub_type.CD_SUB_NORMAL => 96, + LibChdr.chd_sub_type.CD_SUB_RAW => 96, + LibChdr.chd_sub_type.CD_SUB_NONE => 0, + _ => throw new CHDParseException("Malformed CHD format: Invalid sub type!"), + }; + } + + private static readonly string[] _metadataTags = { "TRACK", "TYPE", "SUBTYPE", "FRAMES", "PREGAP", "PGTYPE", "PGSUB", "POSTGAP" }; + + private static CHDCdMetadata ParseMetadata2(string metadata) + { + var strs = metadata.Split(' '); + if (strs.Length != 8) + { + throw new CHDParseException("Malformed CHD format: Incorrect number of metadata tags"); + } + + for (var i = 0; i < 8; i++) + { + var spl = strs[i].Split(':'); + if (spl.Length != 2 || _metadataTags[i] != spl[0]) + { + throw new CHDParseException("Malformed CHD format: Invalid metadata tag"); + } + + strs[i] = spl[1]; + } + + var ret = new CHDCdMetadata(); + try + { + ret.Track = uint.Parse(strs[0]); + ret.TrackType = GetTrackType(strs[1]); + ret.SubType = GetSubType(strs[2]); + ret.Frames = uint.Parse(strs[3]); + ret.Pregap = uint.Parse(strs[4]); + (ret.PregapTrackType, ret.PregapInChd) = GetTrackTypeForPregap(strs[5]); + ret.PregapSubType = GetSubType(strs[6]); + ret.PostGap = uint.Parse(strs[7]); + } + catch (Exception ex) + { + throw ex as CHDParseException ?? new("Malformed CHD format: Metadata parsing threw an exception", ex); + } + + if (ret.PregapInChd && ret.Pregap == 0) + { + throw new CHDParseException("Malformed CHD format: CHD track type indicate it contained pregap data, but no pregap data is present"); + } + + ret.IsCDI = strs[1] == "CDI/2352"; + ret.SectorSize = GetSectorSize(ret.TrackType); + ret.SubSize = GetSubSize(ret.SubType); + ret.Padding = (0 - ret.Frames) & 3; + return ret; + } + + private static CHDCdMetadata ParseMetadata(string metadata) + { + var strs = metadata.Split(' '); + if (strs.Length != 4) + { + throw new CHDParseException("Malformed CHD format: Incorrect number of metadata tags"); + } + + for (var i = 0; i < 4; i++) + { + var spl = strs[i].Split(':'); + if (spl.Length != 2 || _metadataTags[i] != spl[0]) + { + throw new CHDParseException("Malformed CHD format: Invalid metadata tag"); + } + + strs[i] = spl[1]; + } + + var ret = new CHDCdMetadata(); + try + { + ret.Track = uint.Parse(strs[0]); + ret.TrackType = GetTrackType(strs[1]); + ret.SubType = GetSubType(strs[2]); + ret.Frames = uint.Parse(strs[3]); + } + catch (Exception ex) + { + throw ex as CHDParseException ?? new("Malformed CHD format: Metadata parsing threw an exception", ex); + } + + ret.IsCDI = strs[1] == "CDI/2352"; + ret.SectorSize = GetSectorSize(ret.TrackType); + ret.SubSize = GetSubSize(ret.SubType); + ret.Padding = (0 - ret.Frames) & 3; + return ret; + } + + private static void ParseMetadataOld(ICollection cdMetadatas, Span metadata) + { + var numTracks = BinaryPrimitives.ReadUInt32LittleEndian(metadata); + var bigEndian = numTracks > 99; // apparently old metadata can appear as either little endian or big endian + if (bigEndian) + { + numTracks = BinaryPrimitives.ReverseEndianness(numTracks); + } + + if (numTracks > 99) + { + throw new CHDParseException("Malformed CHD format: Invalid number of tracks"); + } + + for (var i = 0; i < numTracks; i++) + { + var track = metadata[(4 + i * 24)..]; + var cdMetadata = new CHDCdMetadata + { + Track = (uint)i + 1 + }; + if (bigEndian) + { + cdMetadata.TrackType = (LibChdr.chd_track_type)BinaryPrimitives.ReadUInt32BigEndian(track); + cdMetadata.SubType = (LibChdr.chd_sub_type)BinaryPrimitives.ReadUInt32BigEndian(track[..4]); + cdMetadata.SectorSize = BinaryPrimitives.ReadUInt32BigEndian(track[..8]); + cdMetadata.SubSize = BinaryPrimitives.ReadUInt32BigEndian(track[..12]); + cdMetadata.Frames = BinaryPrimitives.ReadUInt32BigEndian(track[..16]); + cdMetadata.Padding = BinaryPrimitives.ReadUInt32BigEndian(track[..20]); + } + else + { + cdMetadata.TrackType = (LibChdr.chd_track_type)BinaryPrimitives.ReadUInt32LittleEndian(track); + cdMetadata.SubType = (LibChdr.chd_sub_type)BinaryPrimitives.ReadUInt32LittleEndian(track[..4]); + cdMetadata.SectorSize = BinaryPrimitives.ReadUInt32LittleEndian(track[..8]); + cdMetadata.SubSize = BinaryPrimitives.ReadUInt32LittleEndian(track[..12]); + cdMetadata.Frames = BinaryPrimitives.ReadUInt32LittleEndian(track[..16]); + cdMetadata.Padding = BinaryPrimitives.ReadUInt32LittleEndian(track[..20]); + } + + if (cdMetadata.SectorSize != GetSectorSize(cdMetadata.TrackType)) + { + throw new CHDParseException("Malformed CHD format: Invalid sector size"); + } + + if (cdMetadata.SubSize != GetSubSize(cdMetadata.SubType)) + { + throw new CHDParseException("Malformed CHD format: Invalid sub size"); + } + + var expectedPadding = (0 - cdMetadata.Frames) & 3; + if (cdMetadata.Padding != expectedPadding) + { + throw new CHDParseException("Malformed CHD format: Invalid padding value"); + } + + cdMetadatas.Add(cdMetadata); + } + } + + /// malformed chd format + public static CHDFile ParseFrom(Stream stream) + { + var chdf = new CHDFile(); + try + { + chdf.CoreFile = new(stream); + var err = LibChdr.chd_open_core_file(chdf.CoreFile.CoreFile, LibChdr.CHD_OPEN_READ, IntPtr.Zero, out chdf.ChdFile); + if (err != LibChdr.chd_error.CHDERR_NONE) + { + throw new CHDParseException($"Malformed CHD format: Failed to open chd, got error {err}"); + } + + unsafe + { + var header = (LibChdr.chd_header*)LibChdr.chd_get_header(chdf.ChdFile); + chdf.Header = *header; + } + + if (chdf.Header.hunkbytes == 0 || chdf.Header.hunkbytes % LibChdr.CD_FRAME_SIZE != 0) + { + throw new CHDParseException("Malformed CHD format: Invalid hunk size"); + } + + // libchdr puts the correct value here for older versions of chds which don't have this + // for newer chds, it is left as is, which might be invalid + if (chdf.Header.unitbytes != LibChdr.CD_FRAME_SIZE) + { + throw new CHDParseException("Malformed CHD format: Invalid unit size"); + } + + var metadataOutput = new byte[256]; + for (uint i = 0; i < 99; i++) + { + err = LibChdr.chd_get_metadata(chdf.ChdFile, LibChdr.CDROM_TRACK_METADATA2_TAG, + i, metadataOutput, (uint)metadataOutput.Length, out var resultLen, out _, out _); + if (err == LibChdr.chd_error.CHDERR_NONE) + { + var metadata = Encoding.ASCII.GetString(metadataOutput, 0, (int)resultLen); + chdf.CdMetadatas.Add(ParseMetadata2(metadata)); + continue; + } + + err = LibChdr.chd_get_metadata(chdf.ChdFile, LibChdr.CDROM_TRACK_METADATA_TAG, + i, metadataOutput, (uint)metadataOutput.Length, out resultLen, out _, out _); + if (err == LibChdr.chd_error.CHDERR_NONE) + { + var metadata = Encoding.ASCII.GetString(metadataOutput, 0, (int)resultLen); + chdf.CdMetadatas.Add(ParseMetadata(metadata)); + continue; + } + + // if no more metadata, we're out of tracks + break; + } + + // validate track numbers + if (chdf.CdMetadatas.Where((t, i) => t.Track != i + 1).Any()) + { + throw new CHDParseException("Malformed CHD format: Invalid track number"); + } + + if (chdf.CdMetadatas.Count == 0) + { + // if no metadata was present, we might have "old" metadata instead (which has all track info stored in one entry) + metadataOutput = new byte[4 + 24 * 99]; + err = LibChdr.chd_get_metadata(chdf.ChdFile, LibChdr.CDROM_OLD_METADATA_TAG, + 0, metadataOutput, (uint)metadataOutput.Length, out var resultLen, out _, out _); + if (err == LibChdr.chd_error.CHDERR_NONE) + { + if (resultLen != metadataOutput.Length) + { + throw new CHDParseException("Malformed CHD format: Incorrect length for old metadata"); + } + + ParseMetadataOld(chdf.CdMetadatas, metadataOutput); + } + } + + if (chdf.CdMetadatas.Count == 0) + { + throw new CHDParseException("Malformed CHD format: No tracks present in chd"); + } + + // validation checks + var chdExpectedNumSectors = 0L; + foreach (var cdMetadata in chdf.CdMetadatas) + { + // if pregap is in the chd, then the reported frame count includes both pregap and track data + if (cdMetadata.PregapInChd && cdMetadata.Pregap > cdMetadata.Frames) + { + throw new CHDParseException("Malformed CHD format: Pregap in chd is larger than total sectors in chd track"); + } + + chdExpectedNumSectors += cdMetadata.Frames + cdMetadata.Padding; + } + + var chdActualNumSectors = chdf.Header.hunkcount * (chdf.Header.hunkbytes / LibChdr.CD_FRAME_SIZE); + //if (chdExpectedNumSectors != chdActualNumSectors) // i see some chds with 4 extra sectors of padding in the end? + if (chdExpectedNumSectors > chdActualNumSectors) + { + throw new CHDParseException("Malformed CHD format: Mismatch in expected and actual number of sectors present"); + } + + return chdf; + } + catch (Exception ex) + { + if (chdf.ChdFile != IntPtr.Zero) + { + LibChdr.chd_close(chdf.ChdFile); + } + + if (chdf.CoreFile == null) + { + stream.Dispose(); + } + + chdf.CoreFile?.Dispose(); + throw ex as CHDParseException ?? new("Malformed CHD format: An unknown exception was thrown while parsing", ex); + } + } + + public class LoadResults + { + public CHDFile ParsedCHDFile; + public bool Valid; + public CHDParseException FailureException; + public string ChdPath; + } + + public static LoadResults LoadCHDPath(string path) + { + var ret = new LoadResults + { + ChdPath = path + }; + try + { + if (!File.Exists(path)) throw new CHDParseException("Malformed CHD format: Nonexistent CHD file!"); + + var infCHD = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + ret.ParsedCHDFile = ParseFrom(infCHD); + ret.Valid = true; + } + catch (CHDParseException ex) + { + ret.FailureException = ex; + } + + return ret; + } + + /// + /// CHD is dumb and byteswaps audio samples for some reason + /// + private class SS_CHD_Audio : SS_Base + { + public override void Synth(SectorSynthJob job) + { + // read the sector user data + if ((job.Parts & ESectorSynthPart.User2352) != 0) + { + Blob.Read(BlobOffset, job.DestBuffer2448, job.DestOffset, 2352); + EndiannessUtils.MutatingByteSwap16(job.DestBuffer2448.AsSpan().Slice(job.DestOffset, 2352)); + } + + // if subcode is needed, synthesize it + SynthSubchannelAsNeed(job); + } + } + + private class SS_CHD_Sub : ISectorSynthJob2448 + { + private readonly SS_Base _baseSynth; + private readonly bool _isInterleaved; + + public SS_CHD_Sub(SS_Base baseSynth, bool isInterleaved) + { + _baseSynth = baseSynth; + _isInterleaved = isInterleaved; + } + + public void Synth(SectorSynthJob job) + { + if ((job.Parts & ESectorSynthPart.SubcodeAny) != 0) + { + _baseSynth.Blob.Read(_baseSynth.BlobOffset + 2352, job.DestBuffer2448, job.DestOffset + 2352, 96); + + if ((job.Parts & ESectorSynthPart.SubcodeDeinterleave) != 0 && _isInterleaved) + { + SynthUtils.DeinterleaveSubcodeInplace(job.DestBuffer2448, job.DestOffset + 2352); + } + + if ((job.Parts & ESectorSynthPart.SubcodeDeinterleave) == 0 && !_isInterleaved) + { + SynthUtils.InterleaveSubcodeInplace(job.DestBuffer2448, job.DestOffset + 2352); + } + + job.Parts &= ~ESectorSynthPart.SubcodeAny; + } + + _baseSynth.Synth(job); + } + } + + /// file not found + public static Disc LoadCHDToDisc(string chdPath, DiscMountPolicy IN_DiscMountPolicy) + { + var loadResults = LoadCHDPath(chdPath); + if (!loadResults.Valid) + { + throw loadResults.FailureException; + } + + var disc = new Disc(); + try + { + var chdf = loadResults.ParsedCHDFile; + IBlob chdBlob = new Blob_CHD(chdf.CoreFile, chdf.ChdFile, chdf.Header.hunkbytes); + disc.DisposableResources.Add(chdBlob); + + // chds only support 1 session + var session = new DiscSession { Number = 1 }; + var chdOffset = 0L; + foreach (var cdMetadata in chdf.CdMetadatas) + { + 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; + var control = cdMetadata.TrackType != LibChdr.chd_track_type.CD_TRACK_AUDIO + ? EControlQ.DATA + : EControlQ.None; + q.SetStatus(kADR, control); + q.q_tno = BCD2.FromDecimal(0); + q.q_index = BCD2.FromDecimal((int)cdMetadata.Track); + q.Timestamp = 0; + q.zero = 0; + q.AP_Timestamp = disc._Sectors.Count; + q.q_crc = 0; + return new() { QData = q }; + } + + static SS_Base CreateSynth(LibChdr.chd_track_type trackType) + { + return trackType switch + { + LibChdr.chd_track_type.CD_TRACK_MODE1 => new SS_Mode1_2048(), + LibChdr.chd_track_type.CD_TRACK_MODE1_RAW => new SS_2352(), + LibChdr.chd_track_type.CD_TRACK_MODE2 => new SS_Mode2_2336(), + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1 => new SS_Mode2_Form1_2048(), + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2 => new SS_Mode2_Form2_2324(), + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM_MIX => new SS_Mode2_2336(), + LibChdr.chd_track_type.CD_TRACK_MODE2_RAW => new SS_2352(), + LibChdr.chd_track_type.CD_TRACK_AUDIO => new SS_CHD_Audio(), + _ => throw new InvalidOperationException(), + }; + } + + static CueTrackType ToCueTrackType(LibChdr.chd_track_type chdTrackType, bool isCdi) + { + // rough matches, not too important if these are somewhat wrong (they're just used for generated gaps) + return chdTrackType switch + { + LibChdr.chd_track_type.CD_TRACK_MODE1 => CueTrackType.Mode1_2048, + LibChdr.chd_track_type.CD_TRACK_MODE1_RAW => CueTrackType.Mode1_2352, + LibChdr.chd_track_type.CD_TRACK_MODE2 => CueTrackType.Mode2_2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1 => CueTrackType.Mode2_2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2 => CueTrackType.Mode2_2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_FORM_MIX => CueTrackType.Mode2_2336, + LibChdr.chd_track_type.CD_TRACK_MODE2_RAW when isCdi => CueTrackType.CDI_2352, + LibChdr.chd_track_type.CD_TRACK_MODE2_RAW => CueTrackType.Mode2_2352, + LibChdr.chd_track_type.CD_TRACK_AUDIO => CueTrackType.Audio, + _ => throw new InvalidOperationException(), + }; + } + + var pregapLength = cdMetadata.Pregap; + // force 150 sector pregap for the first track if not present in the chd + if (!cdMetadata.PregapInChd && cdMetadata.Track == 1) + { + cdMetadata.PregapTrackType = cdMetadata.TrackType; + cdMetadata.PregapSubType = cdMetadata.SubType; + pregapLength = 150; + } + + var relMSF = -pregapLength; + for (var i = 0; i < pregapLength; i++) + { + SS_Base synth; + if (cdMetadata.PregapInChd) + { + synth = CreateSynth(cdMetadata.PregapTrackType); + synth.Blob = chdBlob; + synth.BlobOffset = chdOffset; + } + else + { + synth = new SS_Gap { TrackType = ToCueTrackType(cdMetadata.PregapTrackType, cdMetadata.IsCDI) }; + } + + synth.Policy = IN_DiscMountPolicy; + const byte kADR = 1; + var control = cdMetadata.PregapTrackType != LibChdr.chd_track_type.CD_TRACK_AUDIO + ? EControlQ.DATA + : EControlQ.None; + synth.sq.SetStatus(kADR, control); + synth.sq.q_tno = BCD2.FromDecimal((int)cdMetadata.Track); + synth.sq.q_index = BCD2.FromDecimal(0); + synth.sq.Timestamp = !IN_DiscMountPolicy.CUE_PregapContradictionModeA + ? (int)relMSF + 1 + : (int)relMSF; + synth.sq.zero = 0; + synth.sq.AP_Timestamp = disc._Sectors.Count; + synth.sq.q_crc = 0; + synth.Pause = true; + + if (cdMetadata.PregapInChd) + { + // wrap the base synth with our special synth if we have subcode in the chd + ISectorSynthJob2448 chdSynth = cdMetadata.PregapSubType switch + { + LibChdr.chd_sub_type.CD_SUB_NORMAL => new SS_CHD_Sub(synth, isInterleaved: true), + LibChdr.chd_sub_type.CD_SUB_RAW => new SS_CHD_Sub(synth, isInterleaved: false), + LibChdr.chd_sub_type.CD_SUB_NONE => synth, + _ => throw new InvalidOperationException(), + }; + + disc._Sectors.Add(chdSynth); + chdOffset += LibChdr.CD_FRAME_SIZE; + } + else + { + disc._Sectors.Add(synth); + } + + relMSF++; + } + + session.RawTOCEntries.Add(EmitRawTOCEntry()); + + var trackLength = cdMetadata.Frames; + if (cdMetadata.PregapInChd) + { + trackLength -= pregapLength; + } + + for (var i = 0; i < trackLength; i++) + { + var synth = CreateSynth(cdMetadata.TrackType); + synth.Blob = chdBlob; + synth.BlobOffset = chdOffset; + synth.Policy = IN_DiscMountPolicy; + const byte kADR = 1; + var control = cdMetadata.TrackType != LibChdr.chd_track_type.CD_TRACK_AUDIO + ? EControlQ.DATA + : EControlQ.None; + synth.sq.SetStatus(kADR, control); + synth.sq.q_tno = BCD2.FromDecimal((int)cdMetadata.Track); + synth.sq.q_index = BCD2.FromDecimal(1); + synth.sq.Timestamp = (int)relMSF; + synth.sq.zero = 0; + synth.sq.AP_Timestamp = disc._Sectors.Count; + synth.sq.q_crc = 0; + synth.Pause = false; + ISectorSynthJob2448 chdSynth = cdMetadata.SubType switch + { + LibChdr.chd_sub_type.CD_SUB_NORMAL => new SS_CHD_Sub(synth, isInterleaved: true), + LibChdr.chd_sub_type.CD_SUB_RAW => new SS_CHD_Sub(synth, isInterleaved: false), + LibChdr.chd_sub_type.CD_SUB_NONE => synth, + _ => throw new InvalidOperationException(), + }; + disc._Sectors.Add(chdSynth); + chdOffset += LibChdr.CD_FRAME_SIZE; + relMSF++; + } + + chdOffset += cdMetadata.Padding * LibChdr.CD_FRAME_SIZE; + + for (var i = 0; i < cdMetadata.PostGap; i++) + { + var synth = new SS_Gap + { + TrackType = ToCueTrackType(cdMetadata.TrackType, cdMetadata.IsCDI), + Policy = IN_DiscMountPolicy + }; + const byte kADR = 1; + var control = cdMetadata.TrackType != LibChdr.chd_track_type.CD_TRACK_AUDIO + ? EControlQ.DATA + : EControlQ.None; + synth.sq.SetStatus(kADR, control); + synth.sq.q_tno = BCD2.FromDecimal((int)cdMetadata.Track); + synth.sq.q_index = BCD2.FromDecimal(2); + synth.sq.Timestamp = (int)relMSF; + synth.sq.zero = 0; + synth.sq.AP_Timestamp = disc._Sectors.Count; + synth.sq.q_crc = 0; + synth.Pause = true; + disc._Sectors.Add(synth); + relMSF++; + } + } + + SessionFormat GuessSessionFormat() + { + foreach (var cdMetadata in chdf.CdMetadatas) + { + if (cdMetadata.IsCDI) + { + return SessionFormat.Type10_CDI; + } + + if (cdMetadata.TrackType is LibChdr.chd_track_type.CD_TRACK_MODE2 + or LibChdr.chd_track_type.CD_TRACK_MODE2_FORM1 + or LibChdr.chd_track_type.CD_TRACK_MODE2_FORM2 + or LibChdr.chd_track_type.CD_TRACK_MODE2_FORM_MIX + or LibChdr.chd_track_type.CD_TRACK_MODE2_RAW) + { + return SessionFormat.Type20_CDXA; + } + } + + return SessionFormat.Type00_CDROM_CDDA; + } + + var TOCMiscInfo = new Synthesize_A0A1A2_Job( + firstRecordedTrackNumber: 1, + lastRecordedTrackNumber: chdf.CdMetadatas.Count, + sessionFormat: GuessSessionFormat(), + leadoutTimestamp: disc._Sectors.Count); + TOCMiscInfo.Run(session.RawTOCEntries); + + disc.Sessions.Add(session); + return disc; + } + catch + { + disc.Dispose(); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/BizHawk.Emulation.DiscSystem/DiscFormats/CUE/CUE_SynthExtras.cs b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CUE/CUE_SynthExtras.cs index b42e494af0..151e3fcf85 100644 --- a/src/BizHawk.Emulation.DiscSystem/DiscFormats/CUE/CUE_SynthExtras.cs +++ b/src/BizHawk.Emulation.DiscSystem/DiscFormats/CUE/CUE_SynthExtras.cs @@ -6,7 +6,7 @@ namespace BizHawk.Emulation.DiscSystem.CUE /// /// Represents a Mode2 Form1 2048-byte sector - /// Only used by MDS + /// Only used by NRG, MDS, and CHD /// internal class SS_Mode2_Form1_2048 : SS_Base { @@ -36,7 +36,7 @@ namespace BizHawk.Emulation.DiscSystem.CUE /// /// Represents a Mode2 Form1 2324-byte sector - /// Only used by MDS + /// Only used by MDS and CHD /// internal class SS_Mode2_Form2_2324 : SS_Base { @@ -82,7 +82,7 @@ namespace BizHawk.Emulation.DiscSystem.CUE /// /// Represents a full 2448-byte sector with interleaved subcode - /// Only used by MDS and CDI + /// Only used by MDS, NRG, and CDI /// internal class SS_2448_Interleaved : SS_Base { diff --git a/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs b/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs index 08ef0781c1..e3c053de40 100644 --- a/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs +++ b/src/BizHawk.Emulation.DiscSystem/DiscMountJob.cs @@ -195,6 +195,9 @@ namespace BizHawk.Emulation.DiscSystem case ".cdi": OUT_Disc = CDI_Format.LoadCDIToDisc(IN_FromPath, IN_DiscMountPolicy); break; + case ".chd": + OUT_Disc = CHD_Format.LoadCHDToDisc(IN_FromPath, IN_DiscMountPolicy); + break; case ".cue": LoadCue(dir, File.ReadAllText(IN_FromPath)); break; diff --git a/src/BizHawk.Emulation.DiscSystem/LibChdr.cs b/src/BizHawk.Emulation.DiscSystem/LibChdr.cs new file mode 100644 index 0000000000..23b3aa5dba --- /dev/null +++ b/src/BizHawk.Emulation.DiscSystem/LibChdr.cs @@ -0,0 +1,266 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +#pragma warning disable IDE1006 + +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global + +namespace BizHawk.Emulation.DiscSystem +{ + /// + /// libchdr bindings + /// TODO: should this be common-ized? chd isn't limited to discs, it could be used for hard disk images (e.g. for MAME) + /// + public static class LibChdr + { + public enum chd_error : int + { + CHDERR_NONE, + CHDERR_NO_INTERFACE, + CHDERR_OUT_OF_MEMORY, + CHDERR_INVALID_FILE, + CHDERR_INVALID_PARAMETER, + CHDERR_INVALID_DATA, + CHDERR_FILE_NOT_FOUND, + CHDERR_REQUIRES_PARENT, + CHDERR_FILE_NOT_WRITEABLE, + CHDERR_READ_ERROR, + CHDERR_WRITE_ERROR, + CHDERR_CODEC_ERROR, + CHDERR_INVALID_PARENT, + CHDERR_HUNK_OUT_OF_RANGE, + CHDERR_DECOMPRESSION_ERROR, + CHDERR_COMPRESSION_ERROR, + CHDERR_CANT_CREATE_FILE, + CHDERR_CANT_VERIFY, + CHDERR_NOT_SUPPORTED, + CHDERR_METADATA_NOT_FOUND, + CHDERR_INVALID_METADATA_SIZE, + CHDERR_UNSUPPORTED_VERSION, + CHDERR_VERIFY_INCOMPLETE, + CHDERR_INVALID_METADATA, + CHDERR_INVALID_STATE, + CHDERR_OPERATION_PENDING, + CHDERR_NO_ASYNC_OPERATION, + CHDERR_UNSUPPORTED_FORMAT + } + + public const int CHD_OPEN_READ = 1; + public const int CHD_OPEN_READWRITE = 2; + + [StructLayout(LayoutKind.Sequential)] + public struct chd_core_file + { + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate ulong FSizeDelegate(IntPtr file); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate nuint FReadDelegate(IntPtr buffer, nuint size, nuint count, IntPtr file); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate int FCloseDelegate(IntPtr file); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate int FSeekDelegate(IntPtr file, long offset, SeekOrigin origin); + + public IntPtr argp; + [MarshalAs(UnmanagedType.FunctionPtr)] + public FSizeDelegate fsize; + [MarshalAs(UnmanagedType.FunctionPtr)] + public FReadDelegate fread; + [MarshalAs(UnmanagedType.FunctionPtr)] + public FCloseDelegate fclose; + [MarshalAs(UnmanagedType.FunctionPtr)] + public FSeekDelegate fseek; + } + + /// + /// Convenience chd_core_file wrapper against a generic Stream + /// + public class CoreFileStreamWrapper : IDisposable + { + private const uint READ_BUFFER_LEN = 8 * CD_FRAME_SIZE; // 8 frames, usual uncompressed hunk size + private readonly byte[] _readBuffer = new byte[READ_BUFFER_LEN]; + + private Stream _s; + + // ReSharper disable once MemberCanBePrivate.Global + private readonly chd_core_file _coreFile; + public readonly IntPtr CoreFile; + + private ulong FSize(IntPtr file) + { + try + { + return (ulong)_s.Length; + } + catch (Exception e) + { + Console.Error.WriteLine(e); + return unchecked((ulong)-1); + } + } + + private nuint FRead(IntPtr buffer, nuint size, nuint count, IntPtr file) + { + nuint ret = 0; + try + { + // note: size will always be 1, so this should never overflow + var numBytesToRead = (uint)Math.Min(size * (ulong)count, uint.MaxValue); + while (numBytesToRead > 0) + { + var numRead = _s.Read(_readBuffer, 0, (int)Math.Min(READ_BUFFER_LEN, numBytesToRead)); + if (numRead == 0) + { + return ret; + } + + Marshal.Copy(_readBuffer, 0, buffer, numRead); + buffer += numRead; + ret += (uint)numRead; + numBytesToRead -= (uint)numRead; + } + + return ret; + } + catch (Exception e) + { + Console.Error.WriteLine(e); + return ret; + } + } + + private int FClose(IntPtr file) + { + if (_s == null) + { + return -1; + } + + _s.Dispose(); + _s = null; + return 0; + } + + private int FSeek(IntPtr file, long offset, SeekOrigin origin) + { + try + { + _s.Seek(offset, origin); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return -1; + } + } + + public CoreFileStreamWrapper(Stream s) + { + if (!s.CanRead || !s.CanSeek) + { + throw new NotSupportedException("The underlying CHD stream must support reading and seeking!"); + } + + _s = s; + _coreFile.fsize = FSize; + _coreFile.fread = FRead; + _coreFile.fclose = FClose; + _coreFile.fseek = FSeek; + // the pointer here must stay alloc'd on the unmanaged size + // as libchdr expects the memory to not move around + CoreFile = Marshal.AllocCoTaskMem(Marshal.SizeOf()); + Marshal.StructureToPtr(_coreFile, CoreFile, fDeleteOld: false); + } + + public void Dispose() + { + Marshal.DestroyStructure(CoreFile); + Marshal.FreeCoTaskMem(CoreFile); + _s?.Dispose(); + } + } + + [DllImport("chdr")] + public static extern chd_error chd_open_core_file(IntPtr file, int mode, IntPtr parent, out IntPtr chd); + + [DllImport("chdr")] + public static extern void chd_close(IntPtr chd); + + public const int CHD_MD5_BYTES = 16; + public const int CHD_SHA1_BYTES = 20; + + // extracted chd header (not the same as the one on disk, but rather an interpreted one by libchdr) + [StructLayout(LayoutKind.Sequential)] + public struct chd_header + { + public uint length; // length of header data + public uint version; // drive format version + public uint flags; // flags field + public unsafe fixed uint compression[4]; // compression type + public uint hunkbytes; // number of bytes per hunk + public uint totalhunks; // total # of hunks represented + public ulong logicalbytes; // logical size of the data + public ulong metaoffset; // offset in file of first metadata + public ulong mapoffset; // TOOD V5 + public unsafe fixed byte md5[CHD_MD5_BYTES]; // overall MD5 checksum + public unsafe fixed byte parentmd5[CHD_MD5_BYTES]; // overall MD5 checksum of parent + public unsafe fixed byte sha1[CHD_SHA1_BYTES]; // overall SHA1 checksum + public unsafe fixed byte rawsha1[CHD_SHA1_BYTES]; // SHA1 checksum of raw data + public unsafe fixed byte parentsha1[CHD_SHA1_BYTES]; // overall SHA1 checksum of parent + public uint unitbytes; // TODO V5 + public ulong unitcount; // TODO V5 + public uint hunkcount; // TODO V5 + public uint mapentrybytes; // length of each entry in a map (V5) + public unsafe byte* rawmap; // raw map data + public uint obsolete_cylinders; // obsolete field -- do not use! + public uint obsolete_sectors; // obsolete field -- do not use! + public uint obsolete_heads; // obsolete field -- do not use! + public uint obsolete_hunksize; // obsolete field -- do not use! + } + + [DllImport("chdr")] + public static extern IntPtr chd_get_header(IntPtr chd); + + [DllImport("chdr")] + public static extern chd_error chd_read(IntPtr chd, uint hunknum, byte[] buffer); + + public const uint CDROM_TRACK_METADATA2_TAG = 0x43485432; // CHT2 + public const uint CDROM_TRACK_METADATA_TAG = 0x43485452; // CHTR + public const uint CDROM_OLD_METADATA_TAG = 0x43484344; // CHCD + + // these formats are more for sscanf, they aren't suitable for C# + public const string CDROM_TRACK_METADATA2_FORMAT = "TRACK:%d TYPE:%s SUBTYPE:%s FRAMES:%d PREGAP:%d PGTYPE:%s PGSUB:%s POSTGAP:%d"; + public const string CDROM_TRACK_METADATA_FORMAT = "TRACK:%d TYPE:%s SUBTYPE:%s FRAMES:%d"; + + public enum chd_track_type : uint + { + CD_TRACK_MODE1 = 0, // mode 1 2048 bytes/sector + CD_TRACK_MODE1_RAW, // mode 1 2352 bytes/sector + CD_TRACK_MODE2, // mode 2 2336 bytes/sector + CD_TRACK_MODE2_FORM1, // mode 2 2048 bytes/sector + CD_TRACK_MODE2_FORM2, // mode 2 2324 bytes/sector + CD_TRACK_MODE2_FORM_MIX, // mode 2 2336 bytes/sector + CD_TRACK_MODE2_RAW, // mode 2 2352 bytes/sector + CD_TRACK_AUDIO, // redbook audio track 2352 bytes/sector (588 samples) + } + + public enum chd_sub_type : uint + { + CD_SUB_NORMAL = 0, // "cooked" 96 bytes per sector + CD_SUB_RAW, // raw uninterleaved 96 bytes per sector + CD_SUB_NONE // no subcode data stored + } + + // hunks should be a multiple of this for cd chds + public const uint CD_FRAME_SIZE = 2352 + 96; + + [DllImport("chdr")] + public static extern chd_error chd_get_metadata( + IntPtr chd, uint searchtag, uint searchindex, byte[] output, uint outputlen, out uint resultlen, out uint resulttag, out byte resultflags); + } +}