feat(tools): cue2cdi batch mode + make target

Add a recursive batch mode to the CUE/BIN -> CDI converter:
- 'cue2cdi --batch DIR' (a directory positional also implies batch)
recursively walks DIR with opendir/readdir, skipping hidden entries
and symlinks, and converts every *.cue (case-insensitive) in place to
<same-dir>/<same-basename>.cdi.
- Sequential queue with per-file progress
('[3/17] Converting: ... OK (N tracks, M sessions)') and a final
'converted X, skipped Y, failed Z' summary; per-file failures print
their error and the queue continues (exit 1 if any failed).
- Skip logic: an existing .cdi newer than the .cue and every referenced
BIN is skipped; --force reconverts regardless. --verify composes with
batch and runs the full per-track verify table on each file.
- Single-file behavior and flags are unchanged (the conversion tail of
main() is factored into convert_parsed(), behavior-identical).

Makefile: new phony 'make cue2cdi' target (host-side $(CC) build,
libc only) alongside the other manual tool targets; not part of the
default build or 'make test'.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Joseph MattielloandClaude Fable 5 committed 2026-07-26 20:37:15 -04:00
1 parent 6b61a8f7c5
commit d812093d34
3 files changed
+306 -35

No files matched your search

+13 -1
View File
@@ -1066,7 +1066,7 @@ test/heap_search: test/heap_search.c
tools: test/dump_pc test/heap_search test/test_cd_boot
endif
.PHONY: clean test lint coverage benchmark acid dsp-diag frame-timing
.PHONY: clean test lint coverage benchmark acid dsp-diag frame-timing cue2cdi
endif
lint:
@@ -1182,6 +1182,18 @@ cd-visual:
echo "built test/tools/cd_visual_verify -- pass CD_VISUAL_DISC=<image> to run"; \
fi
# `make cue2cdi` -- build the standalone CUE/BIN -> DiscJuggler CDI
# converter (host-side tool, libc only; no core, no libretro deps).
# Not part of the default build or `make test`; build it on demand.
#
# Usage:
# make cue2cdi
# ./test/tools/cue2cdi game.cue --verify
# ./test/tools/cue2cdi --batch --verify path/to/dumps/
.PHONY: cue2cdi
cue2cdi:
$(CC) -O2 -Wall -std=c99 -o test/tools/cue2cdi test/tools/cue2cdi.c
print-%:
@echo '$*=$($*)'
+41 -1
View File
@@ -9,15 +9,19 @@ and the emitted file is laid out exactly the way this repo's own parser
## Build
```bash
make cue2cdi
# equivalent to:
cc -O2 -Wall -std=c99 -o test/tools/cue2cdi test/tools/cue2cdi.c
```
Single self-contained C file, libc only.
Single self-contained C file, libc only (batch mode uses POSIX
`opendir`/`readdir`). Not part of the default build or `make test`.
## Usage
```
cue2cdi input.cue [output.cdi] [--verify] [--quiet] [--version]
cue2cdi [--batch] DIR [--verify] [--force] [--quiet]
```
- Default output: input path with the extension replaced by `.cdi`.
@@ -33,6 +37,42 @@ CUEs (INDEX offsets slice the BIN). `AUDIO`, `MODE1/xxxx`, `MODE2/xxxx`
tracks with 2048/2336/2352-byte sectors are supported; sectors are written
at the source's raw size (no 2048→2352 re-framing).
## Batch mode
Point the tool at a directory — either explicitly with `--batch` or just by
passing a directory as the input — and it recursively finds every `*.cue`
(case-insensitive) and converts each one in place to
`<same-dir>/<same-basename>.cdi`:
```bash
make cue2cdi
./test/tools/cue2cdi --batch --verify ~/jaguar-dumps/
./test/tools/cue2cdi ~/jaguar-dumps/ # directory implies --batch
```
Behavior:
- **Recursive walk**: subdirectories are followed; hidden directories/files
(leading `.`) and symlinks are skipped. Files are processed in sorted
path order.
- **Progress**: one line per file —
`[3/17] Converting: path/to/Game.cue ... OK (11 tracks, 2 sessions)` —
and a final summary: `converted X, skipped Y, failed Z`.
- **Skip logic**: if the target `.cdi` already exists and is newer than the
`.cue` and every referenced BIN, the file is skipped with a note.
`--force` reconverts regardless.
- **Failures don't stop the queue**: a broken CUE or missing BIN prints its
error, is counted as failed, and the run continues. Exit code is 0 when
nothing failed, 1 otherwise.
- `--verify` composes with batch mode: each converted image gets the full
per-track verify table (header lines print before each table instead of
the single-line format).
Batch mode takes no output-path argument (outputs are always placed next to
their sources). The per-track conversion chatter of single-file mode is
always suppressed in batch runs — the progress lines and summary are the
output, and they print regardless of `--quiet`.
## The Jaguar multi-session caveat
Jaguar CDs are multi-session: session 1 holds short audio warning track(s),
+252 -33
View File
@@ -26,10 +26,17 @@
*
* Usage:
* cue2cdi input.cue [output.cdi] [--verify] [--quiet] [--version]
* cue2cdi [--batch] DIR [--verify] [--force] [--quiet]
*
* --verify re-parses the produced CDI with the canonical cdirip walk and
* byte-compares every track's payload against the source BIN(s).
*
* Batch mode (a directory argument implies --batch): recursively walks DIR
* (hidden entries and symlinks skipped), converts every *.cue in place to
* <same-dir>/<same-basename>.cdi, skipping targets already newer than their
* sources (--force reconverts). Per-file failures are reported and the queue
* continues; exit code is 1 if any file failed.
*
* Build:
* cc -O2 -Wall -std=c99 -o test/tools/cue2cdi test/tools/cue2cdi.c
*
@@ -44,7 +51,12 @@
#include <ctype.h>
#include <stdint.h>
#define CUE2CDI_VERSION "1.0"
/* Batch mode is host-side POSIX (opendir/readdir/lstat) */
#include <sys/types.h>
#include <sys/stat.h>
#include <dirent.h>
#define CUE2CDI_VERSION "1.1"
#define MAX_TRACKS 99
#define MAX_SESSIONS 16
@@ -927,6 +939,210 @@ static int verify_cdi(const Disc *d, const char *cdiPath)
return 0;
}
/* -------------------------------------------------------------------------
* Conversion driver (shared by single-file and batch modes)
* ------------------------------------------------------------------------- */
/* Default output path: input with the extension replaced by .cdi
* (same logic the single-file mode has always used). */
static void derive_out_path(const char *inPath, char *outPath, size_t size)
{
const char *ext = strrchr(inPath, '.');
size_t stem = ext ? (size_t)(ext - inPath) : strlen(inPath);
if (stem > size - 5)
stem = size - 5;
memcpy(outPath, inPath, stem);
strcpy(outPath + stem, ".cdi");
}
/* Layout + write + optional verify on an already-parsed Disc.
* Returns 0 = OK, 1 = conversion failed, 2 = verify failed. */
static int convert_parsed(Disc *d, const char *inPath, const char *outPath,
int doVerify)
{
if (compute_layout(d) != 0)
return 1;
INFO("%s: %d track(s), %d session(s), %s\n", basename_of(inPath),
d->numTracks, d->numSessions,
d->multiFile ? "multi-file BIN" : "single-file BIN");
if (d->numSessions > 2)
WARN("more than 2 sessions: this core's parser clamps numSessions to 2\n");
if (d->numSessions == 1)
INFO("note: single session — a bootable Jaguar CD needs 2 sessions\n");
INFO("writing %s\n", outPath);
if (write_cdi(d, outPath) != 0)
return 1;
if (doVerify) {
if (verify_cdi(d, outPath) != 0)
return 2;
INFO("verify: all %d track(s) OK\n", d->numTracks);
}
INFO("done: %s\n", outPath);
return 0;
}
/* -------------------------------------------------------------------------
* Batch mode: recursively convert every *.cue under a directory
* ------------------------------------------------------------------------- */
typedef struct {
char **paths;
int count;
int cap;
} PathList;
static int pathlist_add(PathList *pl, const char *path)
{
char *copy;
if (pl->count == pl->cap) {
int ncap = pl->cap ? pl->cap * 2 : 64;
char **np = (char **)realloc(pl->paths, (size_t)ncap * sizeof(char *));
if (!np)
return -1;
pl->paths = np;
pl->cap = ncap;
}
copy = (char *)malloc(strlen(path) + 1);
if (!copy)
return -1;
strcpy(copy, path);
pl->paths[pl->count++] = copy;
return 0;
}
static int cmp_paths(const void *a, const void *b)
{
return strcmp(*(char *const *)a, *(char *const *)b);
}
/* Recursive walk: collect every regular *.cue (case-insensitive) under
* dirPath. Hidden entries (leading '.', which also covers "."/"..") and
* symlinks are skipped; subdirectories are followed. Unreadable
* directories warn and are skipped. Returns -1 only on OOM. */
static int collect_cues(const char *dirPath, PathList *out)
{
DIR *dp = opendir(dirPath);
struct dirent *de;
struct stat st;
char full[PATH_MAX_LEN];
int ret = 0;
if (!dp) {
WARN("cannot open directory: %s\n", dirPath);
return 0;
}
while ((de = readdir(dp)) != NULL) {
if (de->d_name[0] == '.')
continue;
snprintf(full, sizeof(full), "%s/%s", dirPath, de->d_name);
if (lstat(full, &st) != 0)
continue;
if (S_ISLNK(st.st_mode))
continue;
if (S_ISDIR(st.st_mode)) {
if (collect_cues(full, out) != 0)
ret = -1;
} else if (S_ISREG(st.st_mode)) {
const char *ext = strrchr(de->d_name, '.');
if (ext && istrncmp(ext, ".cue", 5) == 0)
if (pathlist_add(out, full) != 0)
ret = -1;
}
}
closedir(dp);
return ret;
}
/* 1 = target CDI exists and is strictly newer than the CUE and every
* referenced BIN (safe to skip); 0 = needs (re)conversion. */
static int cdi_up_to_date(const char *cdiPath, const char *cuePath,
const Disc *d)
{
struct stat stCdi, stSrc;
int i;
if (stat(cdiPath, &stCdi) != 0)
return 0;
if (stat(cuePath, &stSrc) != 0 || stSrc.st_mtime >= stCdi.st_mtime)
return 0;
for (i = 0; i < d->numTracks; i++) {
if (stat(d->tracks[i].binPath, &stSrc) != 0 ||
stSrc.st_mtime >= stCdi.st_mtime)
return 0;
}
return 1;
}
static int batch_convert(const char *rootDir, int doVerify, int doForce)
{
PathList pl = { NULL, 0, 0 };
int i, nConverted = 0, nSkipped = 0, nFailed = 0;
if (collect_cues(rootDir, &pl) != 0) {
ERR("out of memory while scanning %s\n", rootDir);
return 1;
}
if (pl.count == 0) {
ERR("no .cue files found under %s\n", rootDir);
return 1;
}
qsort(pl.paths, (size_t)pl.count, sizeof(char *), cmp_paths);
/* The per-file progress lines are the batch output; silence the
* inner per-track chatter (the --verify table still prints). */
g_quiet = 1;
for (i = 0; i < pl.count; i++) {
const char *cue = pl.paths[i];
char outPath[PATH_MAX_LEN];
Disc d;
int parsed, rc;
derive_out_path(cue, outPath, sizeof(outPath));
parsed = (parse_cue(cue, &d) == 0);
if (parsed && !doForce && cdi_up_to_date(outPath, cue, &d)) {
printf("[%d/%d] Skipping: %s (CDI up to date; --force to reconvert)\n",
i + 1, pl.count, cue);
nSkipped++;
continue;
}
printf("[%d/%d] Converting: %s%s", i + 1, pl.count, cue,
doVerify ? "\n" : " ... ");
fflush(stdout);
if (!parsed) {
printf("%sFAILED\n", doVerify ? " -> " : "");
nFailed++;
continue;
}
rc = convert_parsed(&d, cue, outPath, doVerify);
if (rc == 0) {
printf("%sOK (%d track%s, %d session%s)\n",
doVerify ? " -> " : "",
d.numTracks, d.numTracks == 1 ? "" : "s",
d.numSessions, d.numSessions == 1 ? "" : "s");
nConverted++;
} else {
printf("%sFAILED%s\n", doVerify ? " -> " : "",
rc == 2 ? " (verify)" : "");
nFailed++;
}
}
printf("converted %d, skipped %d, failed %d\n",
nConverted, nSkipped, nFailed);
for (i = 0; i < pl.count; i++)
free(pl.paths[i]);
free(pl.paths);
return nFailed ? 1 : 0;
}
/* -------------------------------------------------------------------------
* main
* ------------------------------------------------------------------------- */
@@ -935,13 +1151,22 @@ static void usage(FILE *to)
{
fprintf(to,
"usage: cue2cdi input.cue [output.cdi] [--verify] [--quiet] [--version]\n"
" cue2cdi [--batch] DIR [--verify] [--force] [--quiet]\n"
"\n"
"Converts a (possibly multi-session) CUE/BIN image to DiscJuggler V3\n"
".cdi, preserving session structure and track types. Default output is\n"
"the input path with the extension replaced by .cdi.\n"
"\n"
"With --batch (implied when the input is a directory), recursively\n"
"finds every *.cue under DIR and converts each in place to\n"
"<same-dir>/<same-basename>.cdi. Hidden directories and symlinks are\n"
"skipped. Targets newer than their CUE + BIN(s) are skipped; failures\n"
"are reported and the queue continues (exit 1 if any file failed).\n"
"\n"
" --verify re-parse the produced CDI (canonical cdirip walk) and\n"
" byte-compare every track payload against the BIN(s)\n"
" --batch treat the input as a directory tree of CUE/BIN sets\n"
" --force batch mode: reconvert even if the CDI is up to date\n"
" --quiet suppress progress output\n"
" --version print version and exit\n"
"\n"
@@ -955,12 +1180,20 @@ int main(int argc, char **argv)
const char *outArg = NULL;
char outPath[PATH_MAX_LEN];
int doVerify = 0;
int doBatch = 0;
int doForce = 0;
int isDir;
struct stat st;
int i;
Disc d;
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--verify") == 0)
doVerify = 1;
else if (strcmp(argv[i], "--batch") == 0)
doBatch = 1;
else if (strcmp(argv[i], "--force") == 0)
doForce = 1;
else if (strcmp(argv[i], "--quiet") == 0)
g_quiet = 1;
else if (strcmp(argv[i], "--version") == 0) {
@@ -992,6 +1225,20 @@ int main(int argc, char **argv)
return 1;
}
/* Batch mode: explicit --batch, or a directory as the input path */
isDir = (stat(inPath, &st) == 0 && S_ISDIR(st.st_mode));
if (doBatch || isDir) {
if (!isDir) {
ERR("--batch requires a directory: %s\n", inPath);
return 1;
}
if (outArg) {
ERR("batch mode takes a single directory argument\n");
return 1;
}
return batch_convert(inPath, doVerify, doForce);
}
{
const char *ext = strrchr(inPath, '.');
if (ext && istrncmp(ext, ".iso", 5) == 0) {
@@ -1002,40 +1249,12 @@ int main(int argc, char **argv)
}
}
if (outArg) {
if (outArg)
snprintf(outPath, sizeof(outPath), "%s", outArg);
} else {
const char *ext = strrchr(inPath, '.');
size_t stem = ext ? (size_t)(ext - inPath) : strlen(inPath);
if (stem > sizeof(outPath) - 5)
stem = sizeof(outPath) - 5;
memcpy(outPath, inPath, stem);
strcpy(outPath + stem, ".cdi");
}
else
derive_out_path(inPath, outPath, sizeof(outPath));
if (parse_cue(inPath, &d) != 0)
return 1;
if (compute_layout(&d) != 0)
return 1;
INFO("%s: %d track(s), %d session(s), %s\n", basename_of(inPath),
d.numTracks, d.numSessions,
d.multiFile ? "multi-file BIN" : "single-file BIN");
if (d.numSessions > 2)
WARN("more than 2 sessions: this core's parser clamps numSessions to 2\n");
if (d.numSessions == 1)
INFO("note: single session — a bootable Jaguar CD needs 2 sessions\n");
INFO("writing %s\n", outPath);
if (write_cdi(&d, outPath) != 0)
return 1;
if (doVerify) {
if (verify_cdi(&d, outPath) != 0)
return 2;
INFO("verify: all %d track(s) OK\n", d.numTracks);
}
INFO("done: %s\n", outPath);
return 0;
return convert_parsed(&d, inPath, outPath, doVerify);
}