5. Conversion Pipeline (Technical)
This page is a deep dive into the conversion machinery. All references are to CHDStudio/MainWindow.axaml.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(:353): file exists, is.exe, and can be opened for reading with the same sharing Windows uses for executable images (read + delete), so achdman.exerunning under another app instance or held briefly by an antivirus scan no longer produces a false “locked by another process” abort.ValidateChdmanCompatibilityAsync(:436) runschdman helpand gives specific guidance for old-Windows “not a valid application” errors (Win32 error 193, including files mixed from the win-x64 and win-arm64 releases), access-denied (error 5), and abnormal termination (negative exit codes — see Exit-code handling below). - 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 informational notice excluded from bug reports).- 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, extracted from an archive). It hands the image to RecoveredImageClassifier (§8.9): raw 2352-byte CD sectors are sniffed by their sync header and get a generated cue for createcd; a whole number of 2048-byte sectors converts as a DVD image; a whole number of 2336- or 2324-byte Mode 2 sectors gets a MODE2/2336/MODE2/2324 cue; a 2448/2368-byte rip has its subchannel tail stripped to 2352 first; 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/TruncatedPsar → informational skip (user-data conditions, not reported); PbpError.InvalidHeader → the bytes are sniffed and a mislabelled archive/CSO/ISZ/ECM/disc image is routed by content; 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. The notice is informational: it is shown in the log but excluded from automatic bug reports.
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, ResolveOutputCollisions drops 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). The first non-archive input of each colliding group is kept — the original image beats an archived copy of the same disc — and every dropped input is logged with the reason. Combined with the staging file in §5.3 this means a duplicate can neither destroy a finished CHD nor waste an extraction.
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 (:6131): 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 (:6470), which additionally kills stray chdman processes after the second failed attempt (KillChdmanProcesses, :6488).
5.3 ConvertToChdAsync — Encoder Selection (chdman first, built-in CHDSharp fallback)
ConvertToChdAsync (:5342) is the single funnel for every conversion. The mode (createcd/createdvd/createhd/createraw) is chosen once (below). On Windows the bundled chdman is the primary encoder and the built-in CHDSharp encoder is the automatic fallback; on Linux and macOS the built-in CHDSharp encoder is always used, and a chdman found on PATH is not used for encoding.
Primary encoder: chdman
The chdman process-execution path below runs first on Windows. On success the staged .chdtmp file is moved into place and the conversion is done.
If chdman is missing or fails, the run falls back to the built-in CHDSharp encoder (chdman failed for '...'. Falling back to the built-in CHDSharp encoder...): the local TryChdSharpInProcessAsync (:5995) maps the same mode to ChdSharpEncoderService.Encode (Services Reference §7.10) on a fresh output staging path, keeping the already-prepared input (ASCII copy or cue work directory) so the encoder never re-prepares and a cue’s work set stays valid. The encoder writes the CHD in-process and reports failure by exception, and the staged output is moved into place on success. The log line reads CHDSHARP: createcd game.cue, mirroring the chdman invocation it replaces. On Linux and macOS there is no chdman step at all: every file goes straight to the built-in encoder. At startup the app only warns when chdman.exe is missing on Windows (“chdman is the primary encoder”), and the status bar carries an indicator for each encoder — CHDSharp is always green (“built-in”), while CHDMAN is green when found, red when missing on Windows and gray on Linux/macOS, where it is optional.
Batch preflight
PerformBatchConversionAsync (:1684) probes chdman once before the loop on Windows: an executable the OS cannot start, or one that crashes the chdman help compatibility check, is stopped here with a single actionable message instead of once per file. The built-in CHDSharp encoder is always available, so the batch is not refused — a missing chdman routes every file through the built-in encoder (:5528), and a chdman that crashes the startup probe continues with a warning because each file still converts via CHDSharp. On Linux and macOS the built-in encoder is used directly. Duplicate output targets are resolved up front by ResolveOutputCollisions (:1759): the first non-archive input of each colliding group is kept and the rest are skipped with a log line (see Utilities Reference).
Output staging
The conversion writes to <name>.<8hex>.chdtmp next to the destination and moves it into place only after the encoder 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 = isAvi ? "createld"
: forceCd || (!forceDvd && !isIso && !isImg && !isRaw) ? "createcd"
: forceDvd || isIso ? "createdvd"
: isImg ? "createhd"
: "createraw";
-
isAvi— a laserdisc.avialways converts withcreateld(A/Vavhucodec, one frame per hunk), regardless of the Force CD/DVD checkboxes;-usis not added. 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 2352only when the command iscreateraw— chdman’screaterawrequires an explicit unit size when no parent CHD is supplied (“Unit size must be specified if no output parent CHD is supplied”). A.rawinput combined with Force CD or Force DVD runscreatecd/createdvd, which reject the option, so it is not appended there..cue/.tocdescriptors referencing.rawtracks do not get-us—createcddoes not accept the option (“Option ‘-us’ not valid for this command”); it derives the 2352-byte unit size from the cue’s ownMODE1/2352/MODE2/2352track types, so the cue is handed over unchanged.-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(:4900–4916):PrepareCueWorkDirAsync(:4608) →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. Overlong paths (descriptor or referenced files at or beyond MAX_PATH) also trigger the copy-based work directory. - ASCII temp work dir (
:4924–4966): if any part of the input or output path is unsafe for chdman — non-ASCII characters anywhere along the path (an accented user name, a non-Latin folder name) or a total length at or beyond MAX_PATH (260) — the input is copied into an ASCII-safe GUID-named staging directory and the output is written there too; after success the output is moved to the real destination withRetryingFileOperations.TryMoveAsync. Only an unsafe input is staged: an input chdman can read in place (e.g. an ASCII cue whose destination path is overlong) keeps resolving itsFILEentries against its original directory. The staging location itself is chosen byPathUtils.CreateAsciiSafeTempDirectory, because the system temp folder lives under the user profile and can contain exactly the characters this fallback exists to avoid (C:\Users\Kauê Chacon\...).
Process execution (chdman on Windows)
ProcessStartInfowith redirected stdout/stderr,UseShellExecute=false,CreateNoWindow=true.- Output handlers log every line: “Compression complete”/”Extraction complete”/”final ratio” →
[CHDMAN ✓]success lines; everything else (including% completeprogress) →[CHDMAN]log lines. chdman throttles progress to at most one line per half second, and the on-screen log caps its own size (see Application Data), so the live progress cannot flood or freeze the window. - 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(:5173–5194). 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 (
:5210–5226). - Sector-size hard check: 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.” Text descriptors (
.cue/.gdi/.toc) and laserdisc.aviinputs are exempt: their size says nothing about sector geometry. - Disk-space detection (
IsDiskSpaceError,:6313): keywords “not enough space”, “not enough disk space”, “disk full”, “no space left”, “insufficient disk space”. - Error line selection (
SelectChdmanErrorLine,:5546): 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.Input/output errorlines get extra guidance (failing or disconnected drive, antivirus/cloud-sync locks, damaged image). - Abnormal-termination decoding (
DescribeChdmanCrash): a negative exit code means Windows killed chdman before it printed anything. Common NTSTATUS codes are named (e.g.-1073741795→0xC000001D, STATUS_ILLEGAL_INSTRUCTION - the CPU executed an unsupported instruction) with guidance to replacechdman.exewith a CPU-appropriate build and check antivirus quarantine. The batch preflight (ValidateChdmanCompatibilityAsync) runschdman helpfirst; a crash there logs a warning and the batch continues, because the always-available built-in CHDSharp encoder takes over each file, so one clear message replaces a run of per-file failures without refusing the batch. - Path substitution via quoted matching (
:4906,4953,4964,4988): 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. - Diagnostics on unexplained errors: when the selected error line contains “couldn’t find bin file” or “Unknown error”, a capped, sorted directory listing of the input folder is logged (
GetDirectoryDiagnostics).
5.4 Cue Normalization & Work Directories
Two cooperating mechanisms ensure the encoder 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). SharpCompress’s RAR-decoder crashes on malformed data (
NullReferenceException,ArgumentOutOfRangeException,IndexOutOfRangeExceptionfrom inside its PPMd/UnpackV1 code) are classified as corrupt/incomplete archive rather than app errors, so they neither retry through a local copy nor reach the bug-report API. - 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. The same content sniffing extracts ZIP/7z/RAR archives that wear a non-archive extension (for example a.pbpinput that is really a ZIP).
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 (MDSSharp)
.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 first volume (.7z.001/.zip.001) is no longer refused: 7-Zip and ZIP volume sets are byte-splits of one archive stream, so ResolveSplitArchiveSetAsync extracts the set with the bundled 7-Zip — 7za.exe on Windows, 7zz on Linux/macOS (disk space checked against the total size of every volume) — and routes the extracted image through the same classification as a joined set.
Multi-part RAR sets are extracted in-app too, whatever their naming: name.partNN.rar, old-style name.rar + name.rNN, or a RAR set renamed to .001/.002 (routed by content). SharpCompress can only decode a set when it is opened by path through the first volume — opening one part as a stream leaves it without the earlier data and is what produced the decoder crashes — so RarVolumeSet locates the first volume from any part the batch offers. InputFileFilter.RemoveRarVolumeParts keeps only that first volume from the folder scan (or the lowest-numbered part when the first is missing, so extraction still reports the set as incomplete); a .001 set never registers its later parts at all. A set whose parts do not join to a whole number of sectors is reported as needing re-download rather than converted.
ISZ — ISZSharp
Written against EZB Systems’ ISZ File Format Specification 1.00, with the real-file behaviours the specification omits taken from libMirage’s ISZ filter and isz-tool, the two independent readers. IszHeader parses the packed 48-byte header (and UltraISO’s 64-byte extension with its checksums); 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.
- The segment and chunk tables are stored XOR-obfuscated with
B6 8C A5 DE(the complement ofIsZ!) and are de-obfuscated on read. - Bzip2 chunks are stored without the
BZhstream header; it is restored before decompression. - A zero chunk table offset means the image is one raw run, which is decoded without a table.
- Split images may use the spec’s
.i01/.i02naming or the.part01.isz/.part001.iszforms; both are followed from the first segment’s own name. - 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. When the 64-byte header carries UltraISO’s CRC32, the restored image is validated against it and a mismatch fails the same way. - 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. A decoded Mode 2 image can therefore land as 2336 bytes per sector, and one whose source carried subchannel data as 2448/2368 —
RecoveredImageClassifier(see §5.2) routes each layout instead of reporting the file as damaged. - 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% — MDSSharp
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. The descriptor’s data file is located in stages — exact base name beside the .mds, then a unique decorated name (Game (USA).mdf beside Game.mds), then the lone .mdf in the folder, then an unambiguous exact-name match one folder down (split .i00 sets are located the same way, so a sibling game’s set is never picked up); when more than one plausible candidate exists the disc is skipped with a reason instead of guessing. 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 uses the system temp folder (falling back to the roomiest drive only when %TEMP% is unusable), 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.