5. Conversion Pipeline (Technical)
This page is a deep dive into the conversion machinery. All references are to BatchConvertToCHD/MainWindow.xaml.cs unless noted.
5.1 Entry Point & Batch Orchestration
StartConversionButton_ClickAsync (:1025) validates both folder paths (PathUtils.ValidateAndNormalizePath), reads the option flags, renews the cancellation token source, disables the UI, and calls PerformBatchConversionAsync (:1292).
PerformBatchConversionAsync does, in order:
- Executable validation —
ValidateExecutableAccessAsync(:345): file exists, is.exe, is not locked exclusively, and (when not running as admin) is not read-only.ValidateChdmanCompatibilityAsync(:424) runschdman helpand gives specific guidance for old-Windows “not a valid application” errors (Win32 error 193) and access-denied (error 5). - Sorting — when “process smaller files first” is set, files are ordered by ascending size (
:1300–1313). - Disk space check —
CheckDiskSpace(:3292): warns when the output drive’s free space is below 50 % of the total input size for conversion (100 % for extraction), and separately checks the temp drive when it differs from the output drive. - Per-file loop —
ProcessSingleFileForConversionAsync(:1395), with Interlocked ok/failed counters and progress/speed updates.
5.2 Per-File Routing
ProcessSingleFileForConversionAsync decides the output path (source subfolder structure preserved via PathUtils.GetSafeRelativePath + SanitizeFileName), then routes in two stages: content inspection first, extension dispatch second.
Stage 1 — content resolution
TryResolveByContentAsync reads the input’s leading bytes and returns one of three answers:
null— the extension is not lying, so the normal dispatch below applies. This is the common case.ResolvedInput.Convert(path, forceDvd)— convert this file instead (a rejoined image, a decompressed image, or a generated cue).ResolvedInput.Skip(reason)— a user-facing explanation; the file is not handed to chdman at all.
The order matters:
- Text descriptors (
.cue,.gdi,.toc,.ccd,.mds) are skipped — they have their own handlers and there is nothing to sniff. - An archive extension whose content really is an archive returns
null(the archive handler owns it). SplitImageJoiner.TryGetVolumeSet— a.001/.i00first volume is rejoined viaResolveSplitVolumeSetAsync, then classified.DiscImageSignature.Detectdecides the rest:Ecm→ResolveEcmAsync,Isz→ResolveIszAsync,Chd→ skip (“this file is already a CHD”).- An extension promising a container (
.zip/.7z/.rar/.isz) whose content is a plain image →ResolveMislabelledContainerAsync.
ClassifyRecoveredImageAsync is the shared tail for anything recovered into a temp directory (joined from parts, decoded from ECM, decompressed from ISZ): raw CD sectors get a generated cue and createcd; a whole number of 2048-byte sectors converts as a DVD image; anything else is skipped with a reason naming the likely cause.
Stage 2 — extension dispatch
| Extension | Handler | What it does |
|---|---|---|
.cso |
ProcessCsoFileForConversionAsync |
_archiveService.ExtractCsoAsync decompresses to a temp .iso, then converts. |
.zip/.7z/.rar |
ProcessArchiveFileForConversionAsync |
Extracts to a temp dir; drops raw images already covered by a descriptor in the archive (InputFileFilter.RemoveCompanionDataFilesAsync); maps auto-cue outputs; validates cue/gdi/toc dependencies; converts each supported file. .ccd goes through CCDSharp, .mds through the Alcohol parser, .isz through ConvertIszViaImageAsync. |
.pbp |
ProcessPbpFileForConversionAsync |
ExtractPbpToCueBinAsync via PBPSharp; PbpError.InvalidPsarHeader → informational skip; converts each disc cue. |
.ccd |
ProcessCcdFileForConversionAsync |
CcdConverter.Parse + ConvertToCueBin into a temp dir, then converts the cue. |
.mds |
ProcessMdsFileForConversionAsync |
MdsParser + MdsInputPreparer produce a cue (or a stripped image, or a DVD image), then convert. |
| everything else | direct path | TryStageCueForRawImageAsync may generate a cue for a raw CD image, then ValidateDependentFilesAsync → TryDirectConversionAsync. |
Missing input file: logs “File not found, skipping:” and asks FileWatcherService.GetContextForMissingFile for a diagnostic (deleted / renamed / created-then-gone / outside watch / drive disconnected).
Converting in place
The source and output folders may be the same, and the output may sit inside the source tree. Three properties make that safe for conversion:
- The output name is always
<base>.chd, and.chdis not a conversion input, so a source file can never be the target. - Conversions stage to
.chdtmpand move into place only on success (§5.3), so an existing CHD of the same name survives a failed run. - Content inspection recognises an existing CHD and skips it (
"this file is already a CHD"), so outputs cannot be reprocessed on a later run.
PathUtils.IsSameOrInsideDirectory detects the situation and the log notes it once. Extraction is the tab where in-place needs care, because its output shares the source’s base name — see Extraction & Verification.
Batch preflight
Before the per-file loop, WarnAboutOutputCollisions reports inputs that would map to the same output .chd (the name comes from the input’s base name alone, so Game.cue, Game.zip and Game.ccd in one folder all target Game.chd). This is a warning, not a block — but combined with the staging file in §5.3 it means the second conversion can no longer destroy the first one’s output.
Retry-via-temp-copy fallback
If the direct conversion of the original file fails, TryRetryConversionViaTempCopyAsync (:1863) copies the input (plus referenced files for cue/gdi/toc via GameFileParser) into a temp directory and converts there. This handles network paths and file-locking quirks. It also:
- pre-checks temp-drive free space (
:1917–1935), - strips a UTF-8 BOM from cue/toc copies in place (
:1949–1955; chdman’s cue parser chokes on BOMs — see GameFileParser), - copies with
CopyFileWithRetryAsync(:3201; 5 attempts, exponential backoff from 500 ms).
Result handling
HandleConversionResultAsync: on success, optionally deletes originals (see below) and prunes now-empty subfolders (TryDeleteEmptySubfolderAsync). On failure the destination is left alone — a failure says nothing about the CHD already sitting at that path, which may be a good conversion from another input. Temp dirs are always cleaned in a finally block.
Why the destination is no longer deleted on failure. chdman is invoked with
-fand truncates its output file before it can fail, so a second input mapping to the same output name used to wipe out a working CHD. Conversions now write to a<name>.<8hex>.chdtmpstaging file and are moved into place only after success (see §5.3), and the old unconditional “delete the partial output” calls were removed.
Deleting originals — DeleteOriginalGameFilesAsync (:3134): for .cue/.gdi/.toc it also deletes every referenced data file (GameFileParser); for .ccd it deletes the .img/.sub/.cdt companions. All deletions go through RetryingFileOperations.TryDeleteAsync via TryDeleteFileAsync (:3362), which additionally kills stray chdman processes after the second failed attempt (KillChdmanProcesses, :3380).
5.3 ConvertToChdAsync — the chdman Wrapper
ConvertToChdAsync is the single funnel for every chdman invocation.
Output staging
The conversion writes to <name>.<8hex>.chdtmp next to the destination and moves it into place only after chdman reports success. chdman ignores the output file’s extension — verified: createraw -o out.chdtmp exits 0 and writes a valid CHD — and keeping the staging name off .chd also means a leftover staging file is never mistaken for a finished CHD by the verification and extraction tabs.
Command & argument selection
command = forceCd || hasCue || (!forceDvd && !isIso && !isImg && !isRaw) ? "createcd"
: forceDvd || isIso ? "createdvd"
: isImg ? "createhd"
: "createraw";
hasCue = isImg && File.Exists(Path.ChangeExtension(input, ".cue"))— an.imgwith a sibling.cueis treated as a CD image.- Verb choice is still extension-driven here, but this code is now only reached for images that content inspection (§5.2) did not claim. So
Data size ... is not divisible by sector sizeshould now mean a genuinely broken file rather than a mislabelled one. - Base args:
{command} -i "<in>" -o "<out>" -f -np {cores}. .rawinputs get-us 2352(:2478–2481) — chdman’screaterawrequires an explicit unit size when no parent CHD is supplied (“Unit size must be specified if no output parent CHD is supplied”)..cue/.tocdescriptors referencing.rawtracks also get-us 2352— when a cue file references raw audio tracks (e.g.track02.raw), thecreatecdcommand also needs an explicit unit size.GameFileParser.GetReferencedFilesFromCueAsyncis called to check for.rawreferences, and-us 2352is appended to the arguments.-np(processors) comes from a UI/core setting.
Pre-flight validations
- Sector-size warning for DVD:
IsoSectorValidator.GetSectorSizeWarningflags sizes not divisible by 2352/2048/2336/2324/2448/2368, but conversion proceeds — the hard gate is the post-failure check (some legitimate images use non-standard layouts). - Cue work-dir preparation for
.cue/.toc(:2503–2527):PrepareCueWorkDirAsync(:2417) →CueWorkDirectory.PrepareAsync(see Utilities). If MP3 tracks exist and decoding failed, conversion is aborted with a clear message instead of handing chdman an MP3 cue. - ASCII temp work dir (
:2508–2546): if the input or output filename contains non-ASCII characters, the input is copied into a GUID-named temp dir and the output is written there too; after success the output is moved to the real destination withRetryingFileOperations.TryMoveAsync.
Process execution
ProcessStartInfowith redirected stdout/stderr,UseShellExecute=false,CreateNoWindow=true.- Output handlers classify lines: “Compression complete”/”final ratio” → success lines;
% complete/Compressing/Output bytes/Compression ratio→ filtered as progress; everything else →[CHDMAN]log lines. - The stderr buffer accumulates all stderr lines (including progress, which chdman streams to stderr).
- Timeout: when enabled, a linked CTS with
CancelAfter(timeoutMinutes)aborts the wait; the process is killed and the file marked failed with aTIMEOUT:log. - On cancellation/timeout the process is killed (
process.Kill(true)), waited up to 5 s, and temp cleanup is deferred 300 ms so file handles are released.
Exit-code handling
- Success = exit code 0 and no cancellation.
- createdvd fallback: if the error output contains “Unrecognized track type” and the command was
createcdwithout user-forced CD, the app recurses withforceDvd=true(:2694–2699). A recursion depth guard limits this to one retry; exceeding it logsRetry limit reachedinstead of recursing further. - Valid-output tolerance: a non-zero exit that still produced a >0-byte output file is treated as success (
:2701–2716). - Sector-size hard check (
:2761–2794): for non-descriptor inputs, if the file size is not divisible by any of 2352/2048/2336/2324, the conversion fails with “file size … is not divisible by any standard sector size … The file may be corrupt or truncated.” - Disk-space detection (
IsDiskSpaceError,:3281): keywords “not enough space”, “not enough disk space”, “disk full”, “no space left”, “insufficient disk space”. - Error line selection (
SelectChdmanErrorLine,:2860): scans the stderr buffer from the last line upward, skipping progress lines (% complete,Compressing,,Converting,,Output bytes,Compression ratio,ratio=) and theFatal error occurred: Nexit summary, and returns the last real error line. This fixed the class of bugs where the first line of stderr was a progress line (“Compressing, 0.0% complete… (ratio=100.0%)”). When the only output is a fatal error summary, a descriptive message is returned instead of the cryptic exit code. - Path substitution via quoted matching (
:3474,3494,3501,3520): argument paths are replaced using$"\"{originalPath}\""quoted-pattern matching instead of barestring.Replace, preventing a path that is a substring of another argument from causing corruption. - “couldn’t find bin file” diagnostics: when the selected error line contains that phrase, a capped, sorted directory listing of the input folder is logged (
GetDirectoryDiagnostics,:2890).
5.4 Cue Normalization & Work Directories
Two cooperating mechanisms ensure chdman never sees malformed cues:
CueNormalizer.NormalizeAsync— detects the file encoding (BOMs → strict UTF-8 → legacy codepages scored by resolvable references), strips BOMs, unquotes/rewritesFILElines, resolves references case-insensitively and with zero-padding tolerance ((Track 2)↔(Track 02)), and produces a canonical UTF-8 (no BOM) CRLF cue.CueWorkDirectory.PrepareAsync— when the cue needs rewriting (BOM, non-UTF-8, non-ASCII names, MP3 tracks, corrected names), builds an isolated ASCII work directory with the canonical cue and every referenced file under safetrackNN.extnames; MP3 tracks are decoded to WAV. BOM-only cues with ASCII names use an in-place fast path: agame.cuereferencing bins via relative paths, avoiding multi-hundred-MB copies.
Details in Utilities Reference.
5.5 Exception Classifiers
Centralized classification used across the pipeline (:3227–3290):
| Helper | Matches |
|---|---|
IsCancellationException |
OperationCanceledException |
IsDiskSpaceException |
IOException HResult -2147024784 (ERROR_DISK_FULL) or -2147024783 (ERROR_SEM_TIMEOUT) |
IsCrcErrorException |
IOException HResult -2147024809 (ERROR_CRC) or message containing “cyclic redundancy check”/”data error” |
IsCorruptionException |
InvalidDataException, IndexOutOfRangeException, NullReferenceException, CryptographicException, or SharpCompress archive-corruption types (IncompleteArchive, ArchiveOperation, InvalidFormat, LZMA DataError) |
IsDiskSpaceError (string) |
chdman output keywords listed above |
5.6 Archive Processing (Summary)
See Services Reference → ArchiveService for the full extraction semantics. Highlights relevant to the pipeline:
- Pre-extraction disk-space estimate (
CheckTempDiskSpace): ZIP entry sizes are summed; safety margin = estimate + max(estimate/10, 100 MB). - Zip-slip protection: every extracted path must stay under the output directory.
- Post-extraction scan for primary targets (
.cue/.iso/.img/.gdi/.toc/.raw/.ccd/.mds/.isz); if none and bare.binfiles exist, a(Track N)set becomes a multi-FILE cue viaTrackBinCueBuilder, otherwiseBinCueGeneratorproduces a MODE2/2352 auto-cue for the largest bin (auto-cues are retried once with MODE1/2352 on failure). - Error categorization maps SharpCompress/7za failures to actionable messages (missing RAR volume, encrypted archive, unsupported compression method, disk full, locked file, network unavailable).
- A
.rarthat does not start withRar!is no longer assumed corrupt: content sniffing catches the two real cases, a disc image simply given a.rarextension and a.rarset that is a plain byte split rather than an archive.
Still unhandled: an
.ecminside an archive..ecmis not an archive primary target, so an archive containing only.ecmfiles reportsNo supported primary files found in archive..iszis a primary target and shows the pattern to follow.
5.7 Recovered-Image Formats
These all converge on ClassifyRecoveredImageAsync (§5.2) once the image has been reconstructed.
Split volume sets — SplitImageJoiner
.001/.002… and .i00/.i01… sets are concatenated into one temp file. Only the first volume is a registered input, so a set is offered once rather than once per piece. A multi-part archive is detected and refused separately, with instructions, since that needs different tooling. A set whose parts do not join to a whole number of sectors is reported as needing re-download rather than converted.
ISZ — Utilities/Isz/
Written against EZB Systems’ ISZ File Format Specification 1.00. IszHeader parses the packed 48-byte header; IszDecoder walks the chunk table, splitting each entry’s top two bits into the storage kind (ADI_ZERO, ADI_DATA, ADI_ZLIB, ADI_BZ2) and the remainder into the stored length, then decompresses through ZLibStream and SharpCompress’s BZip2Stream.
- Multi-segment images are read as one logical stream over a region per file, so a chunk straddling a segment boundary needs no special case.
- Later segments are matched by volume serial number; a segment belonging to another rip is refused, and a missing one is named.
- Encryption (
has_password≠ 0) is refused by name (AES-128/192/256). - Output is capped at
total_sectors × sect_sizeand the total is checked at the end, so a truncated file is reported instead of yielding a short image that would convert and look fine. - A failed decompression deletes its partial output, for the same reason.
Note that .isz covers two unrelated things in practice: a real ISZ starts with IsZ! and is decompressed, while ordinary images also get renamed to .isz and are routed by content like any other mislabelled file.
ECM — Utilities/Ecm/
ECM shrinks a raw CD image by discarding each sector’s EDC checksum and Reed-Solomon parity, which are derivable from the user data, and recording only what kind of sector each one was. EcmImageDecoder parses the block stream (literal, Mode 1, Mode 2 Form 1, Mode 2 Form 2) and CdSectorEccEdc regenerates the discarded fields.
- No external tool. An earlier version drove Neill Corlett’s UNECM binary, because regenerating parity cannot be trusted without a known-good fixture to check against. That fixture now exists (see Testing), the output is verified byte for byte against the original tool, and the dependency is gone — which also means ARM64 gets ECM like every other format.
- Mode 1 parity covers the sector address; Mode 2 Form 1 parity is computed over a zeroed address so it stays valid when the sector is read without its header. That is exactly what lets ECM store Mode 2 sectors as 2336 bytes and emit the 16-byte sync and header as a literal run.
- Every ECM file ends with a checksum of the whole restored image, which is always validated — a damaged file is reported rather than turned into a plausible one.
Alcohol 120% — Utilities/Mds/
MdsParser reads the descriptor’s session and track tables; MdsInputPreparer picks one of three shapes:
- 2352-byte sectors — only a cue is missing, and the
.mdfis referenced where it lies. - 2448 or 2368 — 2352 data plus a subchannel tail chdman will not read, so the tail is stripped into a new image and a cue is written for that.
- 2048 — the
.mdfis really an ISO and converts as a DVD image.
Split .i00 data files are joined first. A descriptor whose session count is implausible, or whose track modes cannot be expressed in a cue, is refused with the reason. A .mdf is never converted on its own: the .mds drives everything, which is why only .mds is a registered input.
5.8 Generated Cues and the Same-Volume Constraint
Several paths above generate a cue that references a disc image where it already lies, rather than copying a multi-hundred-megabyte file. That works because chdman resolves a cue’s FILE entry against the cue’s own directory.
The catch, verified against chdman 0.285: chdman joins the FILE string to the cue’s directory unconditionally. An absolute path is therefore looked for at C:\temp\x\D:\game.iso and reported as ERROR: couldn't find bin file [...]. A bare file name with the image elsewhere fails the same way.
So a generated cue has to sit on the same volume as its image. StageCueForImageAsync uses PathUtils.CreateTempDirectoryOnSameVolume for this, not GetBestTempDirectory — the latter deliberately picks the roomiest drive, which is right for writing a whole image and wrong for a few hundred bytes of cue with a hard placement constraint. When no writable location exists on the image’s volume the image is converted as-is with a warning.
The .mds path has the same constraint and resolves it differently: it falls back to copying the .mdf into the work directory, which is correct but slow.