using System; using System.Text; using System.IO; using System.Collections.Generic; using System.Linq; namespace BizHawk.Emulation.DiscSystem { /// /// Parsing Alcohol 120% files /// Info taken from: /// * http://forum.redump.org/post/41803/#p41803 /// * Libmirage image-mds parser - https://sourceforge.net/projects/cdemu/files/libmirage/ /// * DiscImageChef - https://github.com/claunia/DiscImageChef/blob/master/DiscImageChef.DiscImages/Alcohol120.cs /// public class MDS_Format { /// /// A loose representation of an Alcohol 120 .mds file (with a few extras) /// public class AFile { /// /// Full path to the MDS file /// public string MDSPath; /// /// MDS Header /// public AHeader Header = new AHeader(); /// /// List of MDS session blocks /// public List Sessions = new List(); /// /// List of track blocks /// public List Tracks = new List(); /// /// Current parsed session objects /// public List ParsedSession = new List(); /// /// Calculated MDS TOC entries (still to be parsed into BizHawk) /// public List TOCEntries = new List(); } public class AHeader { /// /// Standard alcohol 120% signature - usually "MEDIA DESCRIPTOR" /// public string Signature; // 16 bytes /// /// Alcohol version? /// public byte[] Version; // 2 bytes /// /// The medium type /// * 0x00 - CD /// * 0x01 - CD-R /// * 0x02 - CD-RW /// * 0x10 - DVD /// * 0x12 - DVD-R /// public int Medium; /// /// Number of sessions /// public int SessionCount; /// /// Burst Cutting Area length /// public int BCALength; /// /// Burst Cutting Area data offset /// public long BCAOffset; /// /// Offset to disc (DVD?) structures /// public long StructureOffset; /// /// Offset to the first session block /// public long SessionOffset; /// /// Data Position Measurement offset /// public long DPMOffset; /// /// Parse mds stream for the header /// public AHeader Parse(Stream stream) { EndianBitConverter bc = EndianBitConverter.CreateForLittleEndian(); EndianBitConverter bcBig = EndianBitConverter.CreateForBigEndian(); byte[] header = new byte[88]; stream.Read(header, 0, 88); this.Signature = Encoding.ASCII.GetString(header.Take(16).ToArray()); this.Version = header.Skip(16).Take(2).ToArray(); this.Medium = bc.ToInt16(header.Skip(18).Take(2).ToArray()); this.SessionCount = bc.ToInt16(header.Skip(20).Take(2).ToArray()); this.BCALength = bc.ToInt16(header.Skip(26).Take(2).ToArray()); this.BCAOffset = bc.ToInt32(header.Skip(36).Take(4).ToArray()); this.StructureOffset = bc.ToInt32(header.Skip(64).Take(4).ToArray()); this.SessionOffset = bc.ToInt32(header.Skip(80).Take(4).ToArray()); this.DPMOffset = bc.ToInt32(header.Skip(84).Take(4).ToArray()); return this; } } /// /// MDS session block representation /// public class ASession { public int SessionStart; /* Session's start address */ public int SessionEnd; /* Session's end address */ public int SessionNumber; /* Session number */ public byte AllBlocks; /* Number of all data blocks. */ public byte NonTrackBlocks; /* Number of lead-in data blocks */ public int FirstTrack; /* First track in session */ public int LastTrack; /* Last track in session */ public long TrackOffset; /* Offset of lead-in+regular track data blocks. */ } /// /// Representation of an MDS track block /// For convenience (and extra confusion) this also holds the track extrablock, filename(footer) block infos /// as well as the calculated image filepath as specified in the MDS file /// public class ATrack { /// /// The specified data mode /// 0x00 - None (no data) /// 0x02 - DVD /// 0xA9 - Audio /// 0xAA - Mode1 /// 0xAB - Mode2 /// 0xAC - Mode2 Form1 /// 0xAD - Mode2 Form2 /// public byte Mode; /* Track mode */ /// /// Subchannel mode for the track (0x00 = None, 0x08 = Interleaved) /// public byte SubMode; /* Subchannel mode */ /* These are the fields from Sub-channel Q information, which are also returned in full TOC by READ TOC/PMA/ATIP command */ public int ADR_Control; /* Adr/Ctl */ public int TrackNo; /* Track number field */ public int Point; /* Point field (= track number for track entries) */ public int AMin; /* Min */ public int ASec; /* Sec */ public int AFrame; /* Frame */ public int Zero; /* Zero */ public int PMin; /* PMin */ public int PSec; /* PSec */ public int PFrame; /* PFrame */ public long ExtraOffset; /* Start offset of this track's extra block. */ public int SectorSize; /* Sector size. */ public long PLBA; /* Track start sector (PLBA). */ public ulong StartOffset; /* Track start offset (from beginning of MDS file) */ public long Files; /* Number of filenames for this track */ public long FooterOffset; /* Start offset of footer (from beginning of MDS file) */ /// /// Track extra block /// public ATrackExtra ExtraBlock = new ATrackExtra(); /// /// List of footer(filename) blocks for this track /// public List FooterBlocks = new List(); /// /// List of the calculated full paths to this track's image file /// The MDS file itself may contain a filename, or just an *.extension /// public List ImageFileNamePaths = new List(); public int BlobIndex; } /// /// Extra track block /// public class ATrackExtra { public long Pregap; /* Number of sectors in pregap. */ public long Sectors; /* Number of sectors in track. */ } /// /// Footer (filename) block - potentially one for every track /// public class AFooter { public long FilenameOffset; /* Start offset of image filename string (from beginning of mds file) */ public long WideChar; /* Seems to be set to 1 if widechar filename is used */ } /// /// Represents a parsed MDS TOC entry /// public class ATOCEntry { public ATOCEntry(int entryNum) { EntryNum = entryNum; } /// /// these should be 0-indexed /// public int EntryNum; /// /// 1-indexed - the session that this entry belongs to /// public int Session; // /// // /// this seems just to be the LBA corresponding to AMIN:ASEC:AFRAME (give or take 150). It's not stored on the disc, and it's redundant. // /// // public int ALBA; /// /// this seems just to be the LBA corresponding to PMIN:PSEC:PFRAME (give or take 150). /// public int PLBA; //these correspond pretty directly to values in the Q subchannel fields //NOTE: they're specified as absolute MSF. That means, they're 2 seconds off from what they should be when viewed as final TOC values public int ADR_Control; public int TrackNo; public int Point; public int AMin; public int ASec; public int AFrame; public int Zero; public int PMin; public int PSec; public int PFrame; public int SectorSize; public long TrackOffset; /// /// List of the calculated full paths to this track's image file /// The MDS file itself may contain a filename, or just an *.extension /// public List ImageFileNamePaths = new List(); /// /// Track extra block /// public ATrackExtra ExtraBlock = new ATrackExtra(); public int BlobIndex; } /// header is malformed or identifies file as MDS 2.x, or any track has a DVD mode public AFile Parse(Stream stream) { EndianBitConverter bc = EndianBitConverter.CreateForLittleEndian(); EndianBitConverter bcBig = EndianBitConverter.CreateForBigEndian(); bool isDvd = false; var aFile = new AFile { MDSPath = (stream as FileStream).Name }; stream.Seek(0, SeekOrigin.Begin); // check whether the header in the mds file is long enough if (stream.Length < 88) throw new MDSParseException("Malformed MDS format: The descriptor file does not appear to be long enough."); // parse header aFile.Header = aFile.Header.Parse(stream); // check version to make sure this is only v1.x // currently NO support for version 2.x if (aFile.Header.Version[0] > 1) { throw new MDSParseException($"MDS Parse Error: Only MDS version 1.x is supported!\nDetected version: {aFile.Header.Version[0]}.{aFile.Header.Version[1]}"); } // parse sessions Dictionary aSessions = new Dictionary(); stream.Seek(aFile.Header.SessionOffset, SeekOrigin.Begin); for (int se = 0; se < aFile.Header.SessionCount; se++) { byte[] sessionHeader = new byte[24]; stream.Read(sessionHeader, 0, 24); //sessionHeader.Reverse().ToArray(); var session = new ASession { SessionStart = bc.ToInt32(sessionHeader.Take(4).ToArray()), SessionEnd = bc.ToInt32(sessionHeader.Skip(4).Take(4).ToArray()), SessionNumber = bc.ToInt16(sessionHeader.Skip(8).Take(2).ToArray()), AllBlocks = sessionHeader[10], NonTrackBlocks = sessionHeader[11], FirstTrack = bc.ToInt16(sessionHeader.Skip(12).Take(2).ToArray()), LastTrack = bc.ToInt16(sessionHeader.Skip(14).Take(2).ToArray()), TrackOffset = bc.ToInt32(sessionHeader.Skip(20).Take(4).ToArray()) }; //mdsf.Sessions.Add(session); aSessions.Add(session.SessionNumber, session); } long footerOffset = 0; // parse track blocks Dictionary aTracks = new Dictionary(); // iterate through each session block foreach (ASession session in aSessions.Values) { stream.Seek(session.TrackOffset, SeekOrigin.Begin); //Dictionary sessionToc = new Dictionary(); // iterate through every block specified in each session for (int bl = 0; bl < session.AllBlocks; bl++) { byte[] trackHeader; ATrack track = new ATrack(); trackHeader = new byte[80]; stream.Read(trackHeader, 0, 80); track.Mode = trackHeader[0]; track.SubMode = trackHeader[1]; track.ADR_Control = trackHeader[2]; track.TrackNo = trackHeader[3]; track.Point = trackHeader[4]; track.AMin = trackHeader[5]; track.ASec = trackHeader[6]; track.AFrame = trackHeader[7]; track.Zero = trackHeader[8]; track.PMin = trackHeader[9]; track.PSec = trackHeader[10]; track.PFrame = trackHeader[11]; track.ExtraOffset = bc.ToInt32(trackHeader.Skip(12).Take(4).ToArray()); track.SectorSize = bc.ToInt16(trackHeader.Skip(16).Take(2).ToArray()); track.PLBA = bc.ToInt32(trackHeader.Skip(36).Take(4).ToArray()); track.StartOffset = BitConverter.ToUInt64(trackHeader.Skip(40).Take(8).ToArray(), 0); track.Files = bc.ToInt32(trackHeader.Skip(48).Take(4).ToArray()); track.FooterOffset = bc.ToInt32(trackHeader.Skip(52).Take(4).ToArray()); if (track.Mode == 0x02) { isDvd = true; throw new MDSParseException("DVD Detected. Not currently supported!"); } // check for track extra block - this can probably be handled in a separate loop, // but I'll just store the current stream position then seek forward to the extra block for this track long currPos = stream.Position; // Only CDs have extra blocks - for DVDs ExtraOffset = track length if (track.ExtraOffset > 0 && !isDvd) { byte[] extHeader = new byte[8]; stream.Seek(track.ExtraOffset, SeekOrigin.Begin); stream.Read(extHeader, 0, 8); track.ExtraBlock.Pregap = bc.ToInt32(extHeader.Take(4).ToArray()); track.ExtraBlock.Sectors = bc.ToInt32(extHeader.Skip(4).Take(4).ToArray()); stream.Seek(currPos, SeekOrigin.Begin); } else if (isDvd == true) { track.ExtraBlock.Sectors = track.ExtraOffset; } // read the footer/filename block for this track currPos = stream.Position; long numOfFilenames = track.Files; for (long fi = 1; fi <= numOfFilenames; fi++) { // skip leadin/out info tracks if (track.FooterOffset == 0) continue; byte[] foot = new byte[16]; stream.Seek(track.FooterOffset, SeekOrigin.Begin); stream.Read(foot, 0, 16); var f = new AFooter { FilenameOffset = bc.ToInt32(foot.Take(4).ToArray()), WideChar = bc.ToInt32(foot.Skip(4).Take(4).ToArray()) }; track.FooterBlocks.Add(f); track.FooterBlocks = track.FooterBlocks.Distinct().ToList(); // parse the filename string string fileName = "*.mdf"; if (f.FilenameOffset > 0) { // filename offset is present stream.Seek(f.FilenameOffset, SeekOrigin.Begin); byte[] fname; if (numOfFilenames == 1) { if (aFile.Header.DPMOffset == 0) { // filename is in the remaining space to EOF fname = new byte[stream.Length - stream.Position]; } else { // filename is in the remaining space to EOF + dpm offset fname = new byte[aFile.Header.DPMOffset - stream.Position]; } } else { // looks like each filename string is 6 bytes with a trailing \0 fname = new byte[6]; } // read the filename stream.Read(fname, 0, fname.Length); // if widechar is 1 filename is stored using 16-bit, otherwise 8-bit is used if (f.WideChar == 1) fileName = Encoding.Unicode.GetString(fname).TrimEnd('\0'); else fileName = Encoding.Default.GetString(fname).TrimEnd('\0'); } else { // assume an MDF file with the same name as the MDS } string dir = Path.GetDirectoryName(aFile.MDSPath); if (f.FilenameOffset == 0 || string.Compare(fileName, "*.mdf", StringComparison.InvariantCultureIgnoreCase) == 0) { fileName = $@"{dir}\{Path.GetFileNameWithoutExtension(aFile.MDSPath)}.mdf"; } else { fileName = $@"{dir}\{fileName}"; } track.ImageFileNamePaths.Add(fileName); track.ImageFileNamePaths = track.ImageFileNamePaths.Distinct().ToList(); } stream.Position = currPos; aTracks.Add(track.Point, track); aFile.Tracks.Add(track); if (footerOffset == 0) footerOffset = track.FooterOffset; } } // build custom session object aFile.ParsedSession = new List(); foreach (var s in aSessions.Values) { Session session = new Session(); if (!aTracks.TryGetValue(s.FirstTrack, out var startTrack)) { break; } if (!aTracks.TryGetValue(s.LastTrack, out var endTrack)) { break; } session.StartSector = startTrack.PLBA; session.StartTrack = s.FirstTrack; session.SessionSequence = s.SessionNumber; session.EndSector = endTrack.PLBA + endTrack.ExtraBlock.Sectors - 1; session.EndTrack = s.LastTrack; aFile.ParsedSession.Add(session); } // now build the TOC object foreach (var se in aFile.ParsedSession) foreach (var t in aTracks.Values .Where(a => se.StartTrack <= a.TrackNo && a.TrackNo <= se.EndTrack) .OrderBy(a => a.TrackNo)) { aFile.TOCEntries.Add(new ATOCEntry(t.Point) { ADR_Control = t.ADR_Control, AFrame = t.AFrame, AMin = t.AMin, ASec = t.ASec, BlobIndex = t.BlobIndex, EntryNum = t.TrackNo, ExtraBlock = t.ExtraBlock, ImageFileNamePaths = t.ImageFileNamePaths, PFrame = t.PFrame, PLBA = Convert.ToInt32(t.PLBA), PMin = t.PMin, Point = t.Point, PSec = t.PSec, SectorSize = t.SectorSize, Session = se.SessionSequence, TrackOffset = Convert.ToInt64(t.StartOffset), Zero = t.Zero }); } return aFile; } /// /// Custom session object /// public class Session { public long StartSector; public int StartTrack; public int SessionSequence; public long EndSector; public int EndTrack; } public class MDSParseException : Exception { public MDSParseException(string message) : base(message) { } } public class LoadResults { public List RawTOCEntries; public AFile ParsedMDSFile; public bool Valid; public MDSParseException FailureException; public string MdsPath; } public static LoadResults LoadMDSPath(string path) { var ret = new LoadResults { MdsPath = path }; //ret.MdfPath = Path.ChangeExtension(path, ".mdf"); try { if (!File.Exists(path)) throw new MDSParseException("Malformed MDS format: nonexistent MDS file!"); AFile mdsf; using (var infMDS = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) mdsf = new MDS_Format().Parse(infMDS); ret.ParsedMDSFile = mdsf; ret.Valid = true; } catch (MDSParseException ex) { ret.FailureException = ex; } return ret; } /// path reference no longer points to file Dictionary MountBlobs(AFile mdsf, Disc disc) { Dictionary BlobIndex = new Dictionary(); int count = 0; foreach (var track in mdsf.Tracks) { foreach (var file in track.ImageFileNamePaths.Distinct()) { if (!File.Exists(file)) throw new MDSParseException($"Malformed MDS format: nonexistent image file: {file}"); IBlob mdfBlob = null; long mdfLen = -1; //mount the file if (mdfBlob == null) { var mdfFile = new Disc.Blob_RawFile() { PhysicalPath = file }; mdfLen = mdfFile.Length; mdfBlob = mdfFile; } bool dupe = false; foreach (var re in disc.DisposableResources) { if (re.ToString() == mdfBlob.ToString()) dupe = true; } if (!dupe) { // wrap in zeropadadapter disc.DisposableResources.Add(mdfBlob); BlobIndex[count++] = mdfBlob; } } } return BlobIndex; } RawTOCEntry EmitRawTOCEntry(ATOCEntry entry) { BCD2 tno, ino; //this should actually be zero. im not sure if this is stored as BCD2 or not tno = BCD2.FromDecimal(entry.TrackNo); //these are special values.. I think, taken from this: //http://www.staff.uni-mainz.de/tacke/scsi/SCSI2-14.html //the CCD will contain Points as decimal values except for these specially converted decimal values which should stay as BCD. //Why couldn't they all be BCD? I don't know. I guess because BCD is inconvenient, but only A0 and friends have special meaning. It's confusing. ino = BCD2.FromDecimal(entry.Point); if (entry.Point == 0xA0) ino.BCDValue = 0xA0; else if (entry.Point == 0xA1) ino.BCDValue = 0xA1; else if (entry.Point == 0xA2) ino.BCDValue = 0xA2; // get ADR & Control from ADR_Control byte byte adrc = Convert.ToByte(entry.ADR_Control); var Control = adrc & 0x0F; var ADR = adrc >> 4; var q = new SubchannelQ { q_status = SubchannelQ.ComputeStatus(ADR, (EControlQ)(Control & 0xF)), q_tno = tno, q_index = ino, min = BCD2.FromDecimal(entry.AMin), sec = BCD2.FromDecimal(entry.ASec), frame = BCD2.FromDecimal(entry.AFrame), zero = (byte)entry.Zero, ap_min = BCD2.FromDecimal(entry.PMin), ap_sec = BCD2.FromDecimal(entry.PSec), ap_frame = BCD2.FromDecimal(entry.PFrame), q_crc = 0, //meaningless }; return new RawTOCEntry { QData = q }; } /// no file found at or BLOB error public Disc LoadMDSToDisc(string mdsPath, DiscMountPolicy IN_DiscMountPolicy) { var loadResults = LoadMDSPath(mdsPath); if (!loadResults.Valid) throw loadResults.FailureException; Disc disc = new Disc(); // load all blobs Dictionary BlobIndex = MountBlobs(loadResults.ParsedMDSFile, disc); var mdsf = loadResults.ParsedMDSFile; //generate DiscTOCRaw items from the ones specified in the MDS file disc.RawTOCEntries = new List(); foreach (var entry in mdsf.TOCEntries) { disc.RawTOCEntries.Add(EmitRawTOCEntry(entry)); } //analyze the RAWTocEntries to figure out what type of track track 1 is var tocSynth = new Synthesize_DiscTOC_From_RawTOCEntries_Job() { Entries = disc.RawTOCEntries }; tocSynth.Run(); // now build the sectors int currBlobIndex = 0; foreach (var session in mdsf.ParsedSession) { for (int i = session.StartTrack; i <= session.EndTrack; i++) { int relMSF = -1; var track = mdsf.TOCEntries.Where(t => t.Point == i).FirstOrDefault(); if (track == null) break; // ignore the info entries if (track.Point == 0xA0 || track.Point == 0xA1 || track.Point == 0xA2) { continue; } // get the blob(s) for this track // its probably a safe assumption that there will be only one blob per track, // but i'm still not 100% sure on this var tr = (from a in mdsf.TOCEntries where a.Point == i select a).FirstOrDefault(); if (tr == null) throw new MDSParseException("BLOB Error!"); List blobstrings = new List(); foreach (var t in tr.ImageFileNamePaths) { if (!blobstrings.Contains(t)) blobstrings.Add(t); } var tBlobs = (from a in tr.ImageFileNamePaths select a).ToList(); if (tBlobs.Count < 1) throw new MDSParseException("BLOB Error!"); // is the currBlob valid for this track, or do we need to increment? string bString = tBlobs.First(); IBlob mdfBlob = null; // check for track pregap and create if necessary // this is specified in the track extras block if (track.ExtraBlock.Pregap > 0) { CUE.CueTrackType pregapTrackType = CUE.CueTrackType.Audio; if (tocSynth.Result.TOCItems[1].IsData) { if (tocSynth.Result.Session1Format == SessionFormat.Type20_CDXA) pregapTrackType = CUE.CueTrackType.Mode2_2352; else if (tocSynth.Result.Session1Format == SessionFormat.Type10_CDI) pregapTrackType = CUE.CueTrackType.CDI_2352; else if (tocSynth.Result.Session1Format == SessionFormat.Type00_CDROM_CDDA) pregapTrackType = CUE.CueTrackType.Mode1_2352; } for (int pre = 0; pre < track.ExtraBlock.Pregap; pre++) { relMSF++; var ss_gap = new CUE.SS_Gap() { Policy = IN_DiscMountPolicy, TrackType = pregapTrackType }; disc._Sectors.Add(ss_gap); int qRelMSF = pre - Convert.ToInt32(track.ExtraBlock.Pregap); //tweak relMSF due to ambiguity/contradiction in yellowbook docs if (!IN_DiscMountPolicy.CUE_PregapContradictionModeA) qRelMSF++; //setup subQ byte ADR = 1; //absent some kind of policy for how to set it, this is a safe assumption: ss_gap.sq.SetStatus(ADR, tocSynth.Result.TOCItems[1].Control); ss_gap.sq.q_tno = BCD2.FromDecimal(1); ss_gap.sq.q_index = BCD2.FromDecimal(0); ss_gap.sq.AP_Timestamp = pre; ss_gap.sq.Timestamp = qRelMSF; //setup subP ss_gap.Pause = true; } // pregap processing completed } // create track sectors long currBlobOffset = track.TrackOffset; for (long sector = session.StartSector; sector <= session.EndSector; sector++) { CUE.SS_Base sBase = null; // get the current blob from the BlobIndex Disc.Blob_RawFile currBlob = BlobIndex[currBlobIndex] as Disc.Blob_RawFile; long currBlobLength = currBlob.Length; long currBlobPosition = sector; if (currBlobPosition == currBlobLength) currBlobIndex++; mdfBlob = disc.DisposableResources[currBlobIndex] as Disc.Blob_RawFile; //int userSector = 2048; switch (track.SectorSize) { case 2448: sBase = new CUE.SS_2352() { Policy = IN_DiscMountPolicy }; //userSector = 2352; break; case 2048: default: sBase = new CUE.SS_Mode1_2048() { Policy = IN_DiscMountPolicy }; //userSector = 2048; break; //throw new Exception($"Not supported: Sector Size {track.SectorSize}"); } // configure blob sBase.Blob = mdfBlob; sBase.BlobOffset = currBlobOffset; currBlobOffset += track.SectorSize; // userSector; // add subchannel data relMSF++; BCD2 tno, ino; //this should actually be zero. im not sure if this is stored as BCD2 or not tno = BCD2.FromDecimal(track.TrackNo); //these are special values.. I think, taken from this: //http://www.staff.uni-mainz.de/tacke/scsi/SCSI2-14.html //the CCD will contain Points as decimal values except for these specially converted decimal values which should stay as BCD. //Why couldn't they all be BCD? I don't know. I guess because BCD is inconvenient, but only A0 and friends have special meaning. It's confusing. ino = BCD2.FromDecimal(track.Point); if (track.Point == 0xA0) ino.BCDValue = 0xA0; else if (track.Point == 0xA1) ino.BCDValue = 0xA1; else if (track.Point == 0xA2) ino.BCDValue = 0xA2; // get ADR & Control from ADR_Control byte byte adrc = Convert.ToByte(track.ADR_Control); var Control = adrc & 0x0F; var ADR = adrc >> 4; var q = new SubchannelQ { q_status = SubchannelQ.ComputeStatus(ADR, (EControlQ)(Control & 0xF)), q_tno = BCD2.FromDecimal(track.Point), q_index = ino, AP_Timestamp = disc._Sectors.Count, Timestamp = relMSF - Convert.ToInt32(track.ExtraBlock.Pregap) }; sBase.sq = q; disc._Sectors.Add(sBase); } } } return disc; } } //class MDS_Format }