BizHawk/BizHawk.Emulation.DiscSystem/DiscFormats/CUE/CUE_Compile.cs

498 lines
15 KiB
C#

using System;
using System.IO;
using System.Collections.Generic;
//this would be a good place for structural validation
//after this step, we won't want to have to do stuff like that (it will gunk up already sticky code)
namespace BizHawk.Emulation.DiscSystem.CUE
{
internal class CompiledCDText
{
public string Songwriter;
public string Performer;
public string Title;
public string ISRC;
}
internal class CompiledCueIndex
{
public int Number;
/// <summary>
/// this is annoying, it should just be an integer
/// </summary>
public Timestamp FileMSF;
public override string ToString()
{
return $"I#{Number:D2} {FileMSF}";
}
}
/// <summary>
/// What type of file we're looking at.. each one would require a different ingestion handler
/// </summary>
public enum CompiledCueFileType
{
Unknown,
/// <summary>
/// a raw BIN that can be mounted directly
/// </summary>
BIN,
/// <summary>
/// a raw WAV that can be mounted directly
/// </summary>
WAVE,
/// <summary>
/// an ECM file that can be mounted directly (once the index is generated)
/// </summary>
ECM,
/// <summary>
/// An encoded audio file which can be seeked on the fly, therefore roughly mounted on the fly
/// THIS ISN'T SUPPORTED YET
/// </summary>
SeekAudio,
/// <summary>
/// An encoded audio file which can't be seeked on the fly. It must be decoded to a temp buffer, or pre-discohawked
/// </summary>
DecodeAudio,
}
internal class CompiledCueFile
{
public string FullPath;
public CompiledCueFileType Type;
public override string ToString()
{
return $"{Type}: {Path.GetFileName(FullPath)}";
}
}
internal class CompiledDiscInfo
{
public int FirstRecordedTrackNumber, LastRecordedTrackNumber;
public SessionFormat SessionFormat;
}
internal class CompiledCueTrack
{
public int BlobIndex;
public int Number;
/// <summary>
/// A track that's final in a file gets its length from the length of the file; other tracks lengths are determined from the succeeding track
/// </summary>
public bool IsFinalInFile;
/// <summary>
/// A track that's first in a file has an implicit index 0 at 00:00:00
/// Otherwise it has an implicit index 0 at the placement of the index 1
/// </summary>
public bool IsFirstInFile;
public CompiledCDText CDTextData = new CompiledCDText();
public Timestamp PregapLength, PostgapLength;
public CueTrackFlags Flags = CueTrackFlags.None;
public CueTrackType TrackType = CueTrackType.Unknown;
public List<CompiledCueIndex> Indexes = new List<CompiledCueIndex>();
public override string ToString()
{
var idx = Indexes.Find((i) => i.Number == 1);
if (idx == null)
return $"T#{Number:D2} NO INDEX 1";
else
{
var indexlist = string.Join("|", Indexes);
return $"T#{Number:D2} {BlobIndex}:{idx.FileMSF} ({indexlist})";
}
}
}
internal class CompileCueJob : DiscJob
{
/// <summary>
/// input: the CueFile to analyze
/// </summary>
public CUE_File IN_CueFile;
/// <summary>
/// The context used for this compiling job
/// TODO - rename something like context
/// </summary>
public CUE_Context IN_CueContext;
/// <summary>
/// output: high level disc info
/// </summary>
public CompiledDiscInfo OUT_CompiledDiscInfo;
/// <summary>
/// output: CD-Text set at the global level (before any track commands)
/// </summary>
public CompiledCDText OUT_GlobalCDText;
/// <summary>
/// output: The compiled file info
/// </summary>
public List<CompiledCueFile> OUT_CompiledCueFiles;
/// <summary>
/// output: The compiled track info
/// </summary>
public List<CompiledCueTrack> OUT_CompiledCueTracks;
/// <summary>
/// output: An integer between 0 and 10 indicating how costly it will be to load this disc completely.
/// Activites like decoding non-seekable media will increase the load time.
/// 0 - Requires no noticeable time
/// 1 - Requires minimal processing (indexing ECM)
/// 10 - Requires ages, decoding audio data, etc.
/// </summary>
public int OUT_LoadTime;
//-----------------------------------------------------------------
CompiledCDText curr_cdtext;
int curr_blobIndex = -1;
CompiledCueTrack curr_track = null;
CompiledCueFile curr_file = null;
bool discinfo_session1Format_determined = false;
bool curr_fileHasTrack = false;
void UpdateDiscInfo(CUE_File.Command.TRACK trackCommand)
{
if (OUT_CompiledDiscInfo.FirstRecordedTrackNumber == 0)
OUT_CompiledDiscInfo.FirstRecordedTrackNumber = trackCommand.Number;
OUT_CompiledDiscInfo.LastRecordedTrackNumber = trackCommand.Number;
if (!discinfo_session1Format_determined)
{
switch (trackCommand.Type)
{
case CueTrackType.Mode2_2336:
case CueTrackType.Mode2_2352:
OUT_CompiledDiscInfo.SessionFormat = SessionFormat.Type20_CDXA;
discinfo_session1Format_determined = true;
break;
case CueTrackType.CDI_2336:
case CueTrackType.CDI_2352:
OUT_CompiledDiscInfo.SessionFormat = SessionFormat.Type10_CDI;
discinfo_session1Format_determined = true;
break;
default:
break;
}
}
}
void CloseFile()
{
if (curr_track != null)
{
//flag this track as the final one in the file
curr_track.IsFinalInFile = true;
}
curr_file = null;
}
void OpenFile(CUE_File.Command.FILE f)
{
if (curr_file != null)
CloseFile();
curr_blobIndex++;
curr_fileHasTrack = false;
var Resolver = IN_CueContext.Resolver;
//TODO - smart audio file resolving only for AUDIO types. not BINARY or MOTOROLA or AIFF or ECM or what have you
var options = Resolver.Resolve(f.Path);
string choice = null;
if (options.Count == 0)
{
Error($"Couldn't resolve referenced cue file: {f.Path} ; you can commonly repair the cue file yourself, or a file might be missing");
//add a null entry to keep the count from being wrong later (quiets a warning)
OUT_CompiledCueFiles.Add(null);
return;
}
else
{
choice = options[0];
if (options.Count > 1)
Warn($"Multiple options resolving referenced cue file; choosing: {Path.GetFileName(choice)}");
}
var cfi = new CompiledCueFile();
curr_file = cfi;
OUT_CompiledCueFiles.Add(cfi);
cfi.FullPath = choice;
//determine the CueFileInfo's type, based on extension and extra checking
//TODO - once we reorganize the file ID stuff, do legit checks here (this is completely redundant with the fileID system
//TODO - decode vs stream vs unpossible policies in input policies object (including ffmpeg availability-checking callback (results can be cached))
string blobPathExt = Path.GetExtension(choice).ToUpperInvariant();
if (blobPathExt == ".BIN" || blobPathExt == ".IMG") cfi.Type = CompiledCueFileType.BIN;
else if (blobPathExt == ".ISO") cfi.Type = CompiledCueFileType.BIN;
else if (blobPathExt == ".WAV")
{
//quickly, check the format. turn it to DecodeAudio if it can't be supported
//TODO - fix exception-throwing inside
//TODO - verify stream-disposing semantics
var fs = File.OpenRead(choice);
using var blob = new Disc.Blob_WaveFile();
try
{
blob.Load(fs);
cfi.Type = CompiledCueFileType.WAVE;
}
catch
{
cfi.Type = CompiledCueFileType.DecodeAudio;
}
}
else if (blobPathExt == ".APE") cfi.Type = CompiledCueFileType.DecodeAudio;
else if (blobPathExt == ".MP3") cfi.Type = CompiledCueFileType.DecodeAudio;
else if (blobPathExt == ".MPC") cfi.Type = CompiledCueFileType.DecodeAudio;
else if (blobPathExt == ".FLAC") cfi.Type = CompiledCueFileType.DecodeAudio;
else if (blobPathExt == ".ECM")
{
cfi.Type = CompiledCueFileType.ECM;
if (!Disc.Blob_ECM.IsECM(choice))
{
Error($"an ECM file was specified or detected, but it isn't a valid ECM file: {Path.GetFileName(choice)}");
cfi.Type = CompiledCueFileType.Unknown;
}
}
else
{
Error($"Unknown cue file type. Since it's likely an unsupported compression, this is an error: {Path.GetFileName(choice)}");
cfi.Type = CompiledCueFileType.Unknown;
}
//TODO - check for mismatches between track types and file types, or is that best done when interpreting the commands?
}
void CreateTrack1Pregap()
{
if (OUT_CompiledCueTracks[1].PregapLength.Sector == 0) { }
else if (OUT_CompiledCueTracks[1].PregapLength.Sector == 150) { }
else
{
Error("Track 1 specified an illegal pregap. It's being ignored and replaced with a 00:02:00 pregap");
}
OUT_CompiledCueTracks[1].PregapLength = new Timestamp(150);
}
void FinalAnalysis()
{
//some quick checks:
if (OUT_CompiledCueFiles.Count == 0)
Error("Cue file doesn't specify any input files!");
//we can't reliably analyze the length of files here, because we might have to be decoding to get lengths (VBR mp3s)
//REMINDER: we could actually scan the mp3 frames in software
//So, it's not really worth the trouble. We'll cope with lengths later
//we could check the format of the wav file here, though
//score the cost of loading the file
bool needsCodec = false;
OUT_LoadTime = 0;
foreach (var cfi in OUT_CompiledCueFiles)
{
if (cfi == null)
continue;
if (cfi.Type == CompiledCueFileType.DecodeAudio)
{
needsCodec = true;
OUT_LoadTime = Math.Max(OUT_LoadTime, 10);
}
if (cfi.Type == CompiledCueFileType.SeekAudio)
needsCodec = true;
if (cfi.Type == CompiledCueFileType.ECM)
OUT_LoadTime = Math.Max(OUT_LoadTime, 1);
}
//check whether processing was available
if (needsCodec)
{
FFMpeg ffmpeg = new FFMpeg();
if (!ffmpeg.QueryServiceAvailable())
Warn("Decoding service will be required for further processing, but is not available");
}
}
void CloseTrack()
{
if (curr_track == null)
return;
//normalize: if an index 0 is missing, add it here
if (curr_track.Indexes[0].Number != 0)
{
var index0 = new CompiledCueIndex();
var index1 = curr_track.Indexes[0];
index0.Number = 0;
index0.FileMSF = index1.FileMSF; //same MSF as index 1 will make it effectively nonexistent
//well now, if it's the first in the file, an implicit index will take its value from 00:00:00 in the file
//this is the kind of thing I sought to solve originally by 'interpreting' the file, but it seems easy enough to handle this way
//my carlin.cue tests this but test cases shouldn't be hard to find
if (curr_track.IsFirstInFile)
index0.FileMSF = new Timestamp(0);
curr_track.Indexes.Insert(0, index0);
}
OUT_CompiledCueTracks.Add(curr_track);
curr_track = null;
}
void OpenTrack(CUE_File.Command.TRACK trackCommand)
{
//assert that a file is open
if(curr_file == null)
{
Error("Track command encountered with no active file");
throw new DiscJobAbortException();
}
curr_track = new CompiledCueTrack();
//spill cdtext data into this track
curr_cdtext = curr_track.CDTextData;
curr_track.BlobIndex = curr_blobIndex;
curr_track.Number = trackCommand.Number;
curr_track.TrackType = trackCommand.Type;
//default flags
if (curr_track.TrackType != CueTrackType.Audio)
curr_track.Flags = CueTrackFlags.DATA;
if (!curr_fileHasTrack)
{
curr_fileHasTrack = curr_track.IsFirstInFile = true;
}
UpdateDiscInfo(trackCommand);
}
void AddIndex(CUE_File.Command.INDEX indexCommand)
{
curr_track.Indexes.Add(new CompiledCueIndex
{
FileMSF = indexCommand.Timestamp,
Number = indexCommand.Number
});
}
public void Run()
{
//in params
var cue = IN_CueFile;
//output state
OUT_GlobalCDText = new CompiledCDText();
OUT_CompiledDiscInfo = new CompiledDiscInfo();
OUT_CompiledCueFiles = new List<CompiledCueFile>();
OUT_CompiledCueTracks = new List<CompiledCueTrack>();
//add a track 0, for addressing convenience.
//note: for future work, track 0 may need emulation (accessible by very negative LBA--the TOC is stored there)
var track0 = new CompiledCueTrack() {
Number = 0,
};
OUT_CompiledCueTracks.Add(track0);
//global cd text will acquire the cdtext commands set before track commands
curr_cdtext = OUT_GlobalCDText;
for (int i = 0; i < cue.Commands.Count; i++)
{
var cmd = cue.Commands[i];
//these commands get dealt with globally. nothing to be done here
//(but in the future we need to accumulate them into the compile pass output)
if (cmd is CUE_File.Command.CATALOG || cmd is CUE_File.Command.CDTEXTFILE) continue;
//nothing to be done for comments
if (cmd is CUE_File.Command.REM) continue;
if (cmd is CUE_File.Command.COMMENT) continue;
//CD-text and related
if (cmd is CUE_File.Command.PERFORMER) curr_cdtext.Performer = (cmd as CUE_File.Command.PERFORMER).Value;
if (cmd is CUE_File.Command.SONGWRITER) curr_cdtext.Songwriter = (cmd as CUE_File.Command.SONGWRITER).Value;
if (cmd is CUE_File.Command.TITLE) curr_cdtext.Title = (cmd as CUE_File.Command.TITLE).Value;
if (cmd is CUE_File.Command.ISRC) curr_cdtext.ISRC = (cmd as CUE_File.Command.ISRC).Value;
//flags can only be set when a track command is running
if (cmd is CUE_File.Command.FLAGS)
{
if (curr_track == null)
Warn("Ignoring invalid flag commands outside of a track command");
else
//take care to |= it here, so the data flag doesn't get cleared
curr_track.Flags |= (cmd as CUE_File.Command.FLAGS).Flags;
}
if (cmd is CUE_File.Command.TRACK)
{
CloseTrack();
OpenTrack(cmd as CUE_File.Command.TRACK);
}
if (cmd is CUE_File.Command.FILE)
{
CloseFile();
OpenFile(cmd as CUE_File.Command.FILE);
}
if (cmd is CUE_File.Command.INDEX)
{
//todo - validate no postgap specified
AddIndex(cmd as CUE_File.Command.INDEX);
}
if (cmd is CUE_File.Command.PREGAP)
{
//validate track open
//validate no indexes
curr_track.PregapLength = (cmd as CUE_File.Command.PREGAP).Length;
}
if (cmd is CUE_File.Command.POSTGAP)
{
curr_track.PostgapLength = (cmd as CUE_File.Command.POSTGAP).Length;
}
}
//it's a bit odd to close the file before closing the track, but...
//we need to be sure to CloseFile first to make sure the track is marked as the final one in the file
CloseFile();
CloseTrack();
CreateTrack1Pregap();
FinalAnalysis();
FinishLog();
} //Run()
} //class CompileCueJob
} //namespace BizHawk.Emulation.DiscSystem