diff --git a/Makefile b/Makefile index 2154321..2b7b9c7 100644 --- a/Makefile +++ b/Makefile @@ -681,7 +681,7 @@ endif SDL_LIBS := $(if $(filter 1,$(SDL_STATIC)),$(SDL_LIBS_STATIC),$(SDL_LIBS_DYNAMIC)) SDL_LIBS_SHARED := $(SDL_LIBS_DYNAMIC) -.PHONY: all clean shared wasm psvita-lib test test-cpu test-cpu-spec test-gte test-cheats test-gpu test-texrep test-raster-select test-present-dst test-spu-width test-mcard-diverge test-cdrom-getlocp test-chd test-zip test-sdl-runtime disc-probe +.PHONY: all clean shared wasm psvita-lib test test-cpu test-cpu-spec test-gte test-cheats test-gpu test-texrep test-raster-select test-present-dst test-spu-width test-mcard-diverge test-cdrom-getlocp test-chd test-zip test-sdl-runtime test-disc-serial disc-probe all: $(BIN) @@ -712,6 +712,7 @@ TEST_GPU_BIN := build/tests/gpu_renderer_parity TEST_CHD_BIN := build/tests/chd_logic TEST_ZIP_BIN := build/tests/zip_integration TEST_SDL_BIN := build/tests/sdl_renderer_smoke +TEST_DISC_SERIAL_BIN := build/tests/disc_serial DISC_PROBE_BIN := build/tests/disc_probe $(TEST_CPU_BIN): tests/cpu_differential.c $(TEST_CORE_SOURCES) | $(TEST_CORE_DEPS) @@ -975,6 +976,25 @@ $(TEST_SDL_BIN): tests/sdl_renderer_smoke.c test-sdl-runtime: $(TEST_SDL_BIN) ./$(TEST_SDL_BIN) +# Disc serial identification (psx/discid.c), which is what gives a .chd its cover art, its +# per-game settings key and its achievements identity. Built with USE_CHD and the real disc +# readers so `--image ` can identify an actual .chd end to end; the gate itself needs no +# game image and no libchdr decode — see the header of tests/disc_serial.c. +# +# psx/discid.h is a PREREQUISITE, not a source: the buffer size and the API contract live there, +# so a header-only change has to relink or the gate keeps passing against a stale binary. +$(TEST_DISC_SERIAL_BIN): tests/disc_serial.c psx/discid.c psx/discid.h psx/perf.c \ + psx/dev/cdrom/disc.c psx/dev/cdrom/cue.c psx/dev/cdrom/list.c psx/dev/cdrom/chd.c \ + psx/dev/cdrom/pbp.c $(CHD_BUILD_DEPS) + mkdir -p $(dir $@) + $(CC) -std=c11 -O2 -g -DUSE_CHD -DPSXE_DIAG_STDIO_DISABLE -I. -Ipsx $(LIBCHDR_INCLUDE_FLAGS) \ + tests/disc_serial.c psx/discid.c psx/perf.c psx/dev/cdrom/disc.c psx/dev/cdrom/cue.c \ + psx/dev/cdrom/list.c psx/dev/cdrom/chd.c psx/dev/cdrom/pbp.c \ + $(CHD_LINK_LIBS) -lm -o $@ + +test-disc-serial: $(TEST_DISC_SERIAL_BIN) + ./$(TEST_DISC_SERIAL_BIN) $(dir $(TEST_DISC_SERIAL_BIN)) + $(DISC_PROBE_BIN): tests/disc_probe.c psx/dev/cdrom/disc.c psx/dev/cdrom/cue.c psx/dev/cdrom/list.c psx/dev/cdrom/chd.c psx/dev/cdrom/pbp.c $(CHD_BUILD_DEPS) mkdir -p $(dir $@) $(CC) -std=c11 -O2 -g -DUSE_CHD -DPSXE_DIAG_STDIO_DISABLE -I. -Ipsx $(LIBCHDR_INCLUDE_FLAGS) \ diff --git a/android/app/src/main/java/com/armsx2/GameInfo.kt b/android/app/src/main/java/com/armsx2/GameInfo.kt index 4e43de3..67e1cbc 100644 --- a/android/app/src/main/java/com/armsx2/GameInfo.kt +++ b/android/app/src/main/java/com/armsx2/GameInfo.kt @@ -311,8 +311,13 @@ data class GameInfo( * Separate from [serial] on purpose. [serial] is the game's identity: RetroAchievements hashes * against it, per-game settings and play time key off it, and a value guessed from a filename * has no business deciding any of those. Art is the one thing a guess can safely drive — the - * worst case is the wrong picture — so a disc the extractor cannot read (a .chd, a damaged - * image) degrades to "cover still works" rather than a blank tile. + * worst case is the wrong picture — so a disc that cannot be identified at all (a damaged + * image, a .zip) degrades to "cover still works" rather than a blank tile. + * + * A .chd used to land here for every single entry, and the dump-name table only covers a + * couple of dozen USA titles, so most CHD libraries got placeholder tiles. It is now read off + * the disc like any other container ([com.armsx2.core.Ps1DiscId]), so this is back to being + * the last resort it was meant to be. */ val coverSerial: String? get() = serial?.takeIf { it.isNotBlank() } ?: com.armsx2.core.Ps1TitleSerials.coverSerialFor(title, uri.lastPathSegment) @@ -482,9 +487,9 @@ fun regionFlagFor(region: String): String? = when (region) { } /** - * Best-effort serial extractor — the FALLBACK for containers - * [com.armsx2.core.Ps1DiscId] cannot read into (.chd/.zip/.exe). Recognized - * dump conventions: + * Best-effort serial extractor — the FALLBACK for a disc that does not identify + * itself (a .zip, an .exe, a damaged image; a .chd now goes to the core's disc + * reader and normally answers for itself). Recognized dump conventions: * "Game (USA) [SLUS-00594].bin" → SLUS-00594 * "Game (USA) [SLUS_005.94].bin" → SLUS-00594 * "SCUS_949.00 - Game.cue" → SCUS-94900 diff --git a/android/app/src/main/java/com/armsx2/core/Ps1DiscId.kt b/android/app/src/main/java/com/armsx2/core/Ps1DiscId.kt index b161090..c9854b8 100644 --- a/android/app/src/main/java/com/armsx2/core/Ps1DiscId.kt +++ b/android/app/src/main/java/com/armsx2/core/Ps1DiscId.kt @@ -5,8 +5,7 @@ import java.io.RandomAccessFile import java.util.Locale /** - * PS1 disc-serial extraction, entirely in Kotlin (no core/JNI needed — the launcher process - * deliberately does not `System.loadLibrary` the 14 MB of SDL2 + libarmsx just to list games). + * PS1 disc-serial extraction. * * Every PS1 game disc carries a `SYSTEM.CNF` in the ISO9660 ROOT DIRECTORY with a line like * `BOOT = cdrom:\SLUS_005.94;1`, naming the boot executable — and that name IS the disc serial. @@ -26,10 +25,25 @@ import java.util.Locale * costs a handful of 2 KB reads instead of 16 MB per game, and reports WHY it failed when it does. * The old byte scan is kept as a last-ditch fallback for images with no readable filesystem. * - * Compressed containers (.chd/.zip) are still skipped (returns null → the cover falls back to - * [Ps1TitleSerials]); decompressing them needs the native core. + * **Compressed containers go to the core.** A `.chd` cannot be read here at all: CHD v5 Huffman- + * compresses its own hunk map, so there is no reaching the filesystem without decompressing it, + * and every CHD in a library therefore used to come back with NO SERIAL — no cover, no per-game + * settings key, no play-time record, just a placeholder tile. Writing a second CHD decoder in + * Kotlin would have been the wrong fix twice over: a few hundred lines of bit-level decoding, and + * a decoder that is a bit wrong returns a plausible WRONG serial rather than failing, which + * silently attaches one game's art and settings to another. So the disc goes to the reader that + * already boots it — [kr.co.iefriends.pcsx2.NativeApp.getDiscSerialForPath], over psx/discid.c, + * through the same vtable the emulated drive reads through. One decoder, one answer. * - * The serial is normalised to psx-covers' filename form: `SLUS_005.94` → `SLUS-00594`. + * That does mean identification can now touch native. It costs nothing: `Pasx2Application`'s + * warm-up thread already `System.loadLibrary`s SDL2 + libarmsx at process start (deliberately, to + * keep the dlopen off the UI thread), and `MainActivity.onCreate` calls `NativeApp.initializeOnce` + * regardless — so the library is up long before a game tile asks for its cover. Every call is + * guarded anyway: with no native binary the probe degrades to exactly what it did before. + * + * The serial is normalised to psx-covers' filename form: `SLUS_005.94` → `SLUS-00594`. The native + * reader normalises to the same shape, on purpose — the two are alternative routes to one + * identity, and a disagreement would split a game's settings in half. */ object Ps1DiscId { @@ -43,7 +57,7 @@ object Ps1DiscId { */ data class Probe( val serial: String?, - /** "iso9660", "rawscan", "cache", or "" when nothing produced a serial. */ + /** "iso9660", "rawscan", "pbp", "native", "cache", or "" when nothing produced a serial. */ val method: String = "", val detail: String = "", ) @@ -170,13 +184,16 @@ object Ps1DiscId { // in 16-sector blocks and the generic reader below cannot see into it at all. if (rom.extension.lowercase(Locale.US) == "pbp") { probePbp(rom)?.let { return it } - return Probe(null, "", "${rom.name}: PBP container carried no readable disc serial") + return withNativeFallback(rom, "${rom.name}: PBP container carried no readable disc serial") } val candidates = runCatching { dataCandidates(rom) }.getOrDefault(emptyList()) if (candidates.isEmpty()) { - return Probe( - null, "", + // .chd (and .zip): nothing here can see inside a compressed container. This is the + // common path for a CHD library, not an edge case — the native reader below is what + // identifies it. + return withNativeFallback( + rom, "${rom.name}: no readable data track " + "(compressed container, or the cue names a file that is not there)", ) @@ -186,7 +203,50 @@ object Ps1DiscId { val hit = probeFile(data, trace) if (hit != null) return Probe(hit.first, hit.second, trace.toString().trimEnd()) } - return Probe(null, "", trace.toString().trimEnd()) + // The Kotlin walk and its byte scan both came up empty. Before settling for a blank tile, + // ask the core's reader — it opens layouts this cannot (and this is free: a disc that + // identified above never reaches here). + return withNativeFallback(rom, trace.toString().trimEnd()) + } + + // ---- native reader (compressed containers, and last resort) ------------------------------ + + /** + * Hand [rom] to the core's own disc reader and take whatever it says, keeping [kotlinDetail] + * in the trace so `serial_probe.log` still records how the Kotlin attempt went. + * + * Never throws and never blocks on anything but the read. With no native binary loaded — the + * JVM unit tests, or a build whose `.so` failed to load — this degrades to the [Probe] the + * caller would have returned anyway, and says so rather than leaving "no cover" and no + * evidence. + */ + private fun withNativeFallback(rom: File, kotlinDetail: String): Probe { + val detail = StringBuilder(kotlinDetail.trimEnd()) + if (detail.isNotEmpty()) detail.append("; ") + + val result = runCatching { + kr.co.iefriends.pcsx2.NativeApp.getDiscSerialForPath(rom.absolutePath) + } + val serial = result.getOrNull()?.trim()?.takeIf { it.isNotBlank() } + + return when { + serial != null -> { + detail.append("core disc reader -> ").append(serial) + Probe(serial, "native", detail.toString()) + } + result.isFailure -> { + // UnsatisfiedLinkError, i.e. no libarmsx in this process. Worth naming: it is the + // difference between "this disc has no serial" and "nothing ever looked". + detail.append("core disc reader unavailable (") + .append(result.exceptionOrNull()?.javaClass?.simpleName ?: "unknown") + .append(')') + Probe(null, "", detail.toString()) + } + else -> { + detail.append("core disc reader found no serial") + Probe(null, "", detail.toString()) + } + } } // ---- data-track resolution ------------------------------------------------------------ diff --git a/android/app/src/main/java/com/armsx2/core/Ps1TitleSerials.kt b/android/app/src/main/java/com/armsx2/core/Ps1TitleSerials.kt index f339f35..77156d0 100644 --- a/android/app/src/main/java/com/armsx2/core/Ps1TitleSerials.kt +++ b/android/app/src/main/java/com/armsx2/core/Ps1TitleSerials.kt @@ -6,9 +6,14 @@ import java.util.Locale * Last-resort **cover-art only** serial lookup, by No-Intro / Redump dump name. * * [Ps1DiscId] reads the serial off the disc itself and that is the answer wherever it works. This - * table exists so a disc it *cannot* read — a `.chd` or `.zip` (compressed, needs the native core - * to open), an image whose filesystem is damaged, a homebrew-style disc whose boot executable is - * named `PSX.EXE` — degrades to "cover art still works" instead of a blank tile. + * table exists so a disc it *cannot* read — a `.zip`, an image whose filesystem is damaged, a + * homebrew-style disc whose boot executable is named `PSX.EXE` — degrades to "cover art still + * works" instead of a blank tile. + * + * It is no longer the main answer for `.chd`. It used to be, and that was the bug: a CHD never + * yielded a serial, so covers came from this table — a couple of dozen curated USA titles — and + * every other CHD in a library got a placeholder. CHDs are now identified through the core's own + * disc reader, so this is back to being a genuine last resort. * * **Deliberately not fed into [com.armsx2.GameInfo.serial].** That field is the game's IDENTITY: * RetroAchievements hashes against it, per-game settings key off it, play time accrues under it. diff --git a/android/app/src/main/java/com/armsx2/data/library/GameLibraryRepository.kt b/android/app/src/main/java/com/armsx2/data/library/GameLibraryRepository.kt index a72ad08..55b8bfc 100644 --- a/android/app/src/main/java/com/armsx2/data/library/GameLibraryRepository.kt +++ b/android/app/src/main/java/com/armsx2/data/library/GameLibraryRepository.kt @@ -277,10 +277,11 @@ class GameLibraryRepository(private val context: Context) { val extension = name.substringAfterLast('.', "").lowercase() val (fileTitle, fileSerial) = FilenameParser.parse(name) // The disc's own boot line beats the filename: a renamed dump still boots the same disc, - // and psx-covers is keyed by the real serial. Null for .chd/.zip/.exe (Ps1DiscId can't - // read inside a compressed container) — those fall back to a serial in the filename, then - // to a dump-name lookup for the cover only (GameInfo.coverSerial), and failing that to a - // placeholder tile. + // and psx-covers is keyed by the real serial. A .chd answers too — Ps1DiscId hands the + // compressed container to the core's disc reader rather than giving up on it. What is + // still null is a .zip/.exe and an image whose filesystem is unreadable; those fall back + // to a serial in the filename, then to a dump-name lookup for the cover only + // (GameInfo.coverSerial), and failing that to a placeholder tile. val probe = runCatching { Ps1Covers.probeForPath(game.path) }.getOrNull() probe?.let { probeLog += describe(name, it) } val serial = probe?.serial ?: fileSerial diff --git a/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index f333263..46eff28 100644 --- a/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -1247,9 +1247,32 @@ public class NativeApp { * parses the BOOT2 line. Handles flat ISO/raw-sector images and CHDs; * CSO/ZSO/GZ still return null and the caller falls back to filename * parsing. fd is consumed (closed by native). + * + * ⚠ STILL A STUB on the PS1 port — it always answers "". The path-based + * {@link #getDiscSerialForPath(String)} below is the real one; this + * descriptor-based variant only exists for the `content://` launch path + * (MainActivityRuntime.externalGameInfo), which therefore still falls back + * to the filename stem for its per-game settings key. */ public static String getGameSerialFromFd(int fd) { return ""; } + /** + * The disc serial an image reports about ITSELF — {@code "SLUS-00594"} — or {@code ""} when + * it carries none. Never null. + * + * Implemented natively (psx/discid.c) against the same disc reader the emulated drive uses, + * so every container the emulator can boot can also be identified: .cue/.bin/.iso/.pbp and, + * the reason this exists, .chd. {@link com.armsx2.core.Ps1DiscId} reads SYSTEM.CNF in + * Kotlin for the formats Java can seek inside of and only calls this for the ones it cannot — + * CHD v5 Huffman-compresses its own hunk map, so reaching the filesystem means decompressing + * it, and a second decoder that is slightly wrong would hand back a plausible WRONG serial + * instead of failing. One decoder, one answer. + * + * [path] is an absolute POSIX path; a {@code content://} URI cannot be opened here. Blocking + * IO (a CHD hunk is decompressed to reach the volume descriptor) — call it off the UI thread. + */ + public static native String getDiscSerialForPath(String path); + /** * PCSX2 game-database compatibility lookup. Returns the raw 0-6 * Compatibility enum value: diff --git a/android/app/src/test/java/com/armsx2/core/Ps1DiscIdTest.kt b/android/app/src/test/java/com/armsx2/core/Ps1DiscIdTest.kt index 7812e15..fe01e49 100644 --- a/android/app/src/test/java/com/armsx2/core/Ps1DiscIdTest.kt +++ b/android/app/src/test/java/com/armsx2/core/Ps1DiscIdTest.kt @@ -187,13 +187,25 @@ class Ps1DiscIdTest { assertEquals("rawscan", probe.method) } + /** + * A `.chd` is handed to the core's disc reader — nothing in Kotlin can see inside a + * compressed container, and that is exactly why every CHD used to come back with no serial + * and no cover. + * + * On this JVM there IS no core: `System.loadLibrary("armsx")` cannot resolve on the build + * machine. So what this pins is the DEGRADATION — a probe that reaches the native reader and + * cannot use it must come back with a null serial and a trace saying so, never a guess and + * never a crash. The identification itself is gated host-side against real disc geometry in + * `tests/disc_serial.c` (`make test-disc-serial`), where the reader actually exists. + */ @Test - fun compressedContainersAreSkippedWithAReason() { + fun compressedContainersGoToTheCoreAndDegradeCleanlyWithoutIt() { val chd = temp.newFile("game.chd") chd.writeBytes(ByteArray(1024)) val probe = Ps1DiscId.probe(chd) assertNull(probe.serial) assertTrue(probe.detail, probe.detail.contains("no readable data track")) + assertTrue(probe.detail, probe.detail.contains("core disc reader")) } // ---- dump-name fallback (cover art only) ------------------------------------------------- diff --git a/frontend/android_jni.cpp b/frontend/android_jni.cpp index 484c30b..f8c1892 100644 --- a/frontend/android_jni.cpp +++ b/frontend/android_jni.cpp @@ -71,6 +71,9 @@ extern "C" { #include "config.h" #include "../psx/pgxp.h" #include "../psx/state.h" +/* Disc serial identification. The launcher reads SYSTEM.CNF in Kotlin for the containers Java + can seek around in; a .chd is the one it cannot, and this is the seam it comes through. */ +#include "../psx/discid.h" /* [cheats] — the GameShark engine. Read psx/cheats.h before touching the four natives at the bottom of this file: the format choice, the threading contract (this file is the UI thread; the emulation thread only ever adopts a published program) and the hardcore interlock are @@ -2284,6 +2287,34 @@ Java_kr_co_iefriends_pcsx2_NativeApp_getAchievementsHashForPath(JNIEnv* env, jcl return Utf8ToJString(env, path.empty() ? std::string() : armsx_ach_hash_for_path(path.c_str())); } +// The serial of a disc image that is NOT mounted — "SLUS-00594" — or "" when it carries none. +// +// This is what gives a .chd its cover art, its per-game settings key and its play-time record. +// com.armsx2.core.Ps1DiscId reads SYSTEM.CNF itself for .bin/.cue/.iso/.pbp and only comes here +// for containers Java cannot seek inside of, because CHD v5 Huffman-compresses its own hunk map: +// there is no way to reach the filesystem without decompressing it, and a second decoder that is +// slightly wrong would return a plausible WRONG serial rather than failing. psx/discid.c goes +// through the same reader the emulated drive does, so whatever boots can be identified. +// +// Reads the disc (and decompresses, for a CHD): the Kotlin side calls it off the UI thread. +// Never returns null — Ps1DiscId treats "" as "no serial" and falls back exactly as before. +extern "C" JNIEXPORT jstring JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getDiscSerialForPath(JNIEnv* env, jclass, jstring image_path) { + const std::string path = JStringToUtf8(env, image_path); + + if (path.empty()) { + return Utf8ToJString(env, std::string()); + } + + char serial[PSX_DISCID_MAX] = {}; + + if (!psx_discid_from_path(path.c_str(), serial, sizeof(serial))) { + return Utf8ToJString(env, std::string()); + } + + return Utf8ToJString(env, std::string(serial)); +} + extern "C" JNIEXPORT jstring JNICALL Java_kr_co_iefriends_pcsx2_NativeApp_getRichPresence(JNIEnv* env, jclass) { return Utf8ToJString(env, armsx_ach_get_rich_presence()); diff --git a/psx/discid.c b/psx/discid.c new file mode 100644 index 0000000..45251b7 --- /dev/null +++ b/psx/discid.c @@ -0,0 +1,489 @@ +/* + ARMSX — disc serial identification. See discid.h for why this exists and what it guarantees. +*/ + +#include "discid.h" + +#include +#include +#include + +#include "log.h" + +/* ISO9660 logical sector: the 2048 bytes of user data inside whatever the container hands back. */ +#define DISCID_USER_BYTES 2048 + +/* Where the primary volume descriptor lives, in ISO sectors. */ +#define DISCID_PVD_SECTOR 16 + +/* How many sectors past a start point to look for it. Wide enough to cover a rip that captured + the pregap ahead of the filesystem, narrow enough that a container with no filesystem at all + costs a handful of reads rather than a scan of the whole image. */ +#define DISCID_VD_SEARCH_SECTORS 64 + +/* ISO9660 directory-record layout, from the start of the record. */ +#define DISCID_DR_LENGTH 0 +#define DISCID_DR_EXTENT_LE 2 +#define DISCID_DR_SIZE_LE 10 +#define DISCID_DR_NAME_LEN 32 +#define DISCID_DR_NAME 33 + +/* Root directory record, from the start of the PVD's user data. */ +#define DISCID_PVD_ROOT_DR 156 + +/* A root directory bigger than this is a corrupt extent, not a directory. */ +#define DISCID_MAX_ROOT_BYTES (16u * 1024u * 1024u) + +/* Longest boot name we will carry ("SLUS_005.94" and friends, plus room for oddities). */ +#define DISCID_MAX_BOOT_NAME 64 + +typedef struct { + /* LBA that ISO sector 0 sits at. 150 on a CHD (the lead-in is part of its LBA space), + 0 on a raw file-backed rip whose first byte is ISO sector 0. */ + uint32_t base_lba; + /* Byte offset of the 2048 user bytes inside the sector the container returns: 24 for + MODE2/2352 (12 sync + 4 header + 8 subheader), 16 for MODE1/2352, 0 for a plain + 2048-byte ISO. */ + uint32_t user_offset; +} discid_layout_t; + +static char discid_tolower(char c) { + return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; +} + +static char discid_toupper(char c) { + return (c >= 'a' && c <= 'z') ? (char)(c - 'a' + 'A') : c; +} + +static int discid_is_digit(char c) { + return c >= '0' && c <= '9'; +} + +static int discid_is_alpha(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); +} + +static uint32_t discid_read_le32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +/* + Reads one ISO sector's 2048 user bytes through `layout`. Returns 1 on success. + + Deliberately tolerant of the sector TYPE: the cue reader reports anything that is not + MODE2/2352 as TS_AUDIO even when it is a MODE1 data track, so demanding TS_DATA here would + refuse to identify perfectly ordinary MODE1 rips. Nothing is trusted on that basis — the + "CD001" check below is what decides whether these bytes are a filesystem. +*/ +static int discid_read_iso(psx_disc_t* disc, const discid_layout_t* layout, + uint32_t iso_sector, uint8_t* user_out) { + uint8_t sector[CD_SECTOR_SIZE]; + int type; + + memset(sector, 0, sizeof(sector)); + + type = psx_disc_read(disc, layout->base_lba + iso_sector, sector); + + if (type == 0 || type == TS_FAR) + return 0; + + memcpy(user_out, sector + layout->user_offset, DISCID_USER_BYTES); + + return 1; +} + +/* Type 1 + "CD001" is a primary volume descriptor. Both are required: without the type byte a + non-ISO image can be walked as if arbitrary bytes were directory records. */ +static int discid_is_pvd(const uint8_t* user) { + return user[0] == 1 && memcmp(user + 1, "CD001", 5) == 0; +} + +/* + Compares an ISO9660 directory-record name against a plain filename, case-insensitively and + ignoring the ";1" version suffix. Both matter: discs store "SYSTEM.CNF;1", and mastering + tools do not agree on case. +*/ +static int discid_name_matches(const uint8_t* record_name, int record_len, const char* wanted) { + int i = 0; + + for (; i < record_len; ++i) { + const char c = (char)record_name[i]; + + if (c == ';') + break; + + if (!wanted[i]) + return 0; + + if (discid_tolower(c) != discid_tolower(wanted[i])) + return 0; + } + + return wanted[i] == '\0'; +} + +/* Walks the root directory for `name`. Fills extent/size and returns 1 on success. */ +static int discid_find_in_root(psx_disc_t* disc, const discid_layout_t* layout, const char* name, + uint32_t* out_extent, uint32_t* out_size) { + uint8_t buffer[DISCID_USER_BYTES]; + uint32_t root_extent, root_size, sector, sectors; + + if (!discid_read_iso(disc, layout, DISCID_PVD_SECTOR, buffer)) + return 0; + + if (!discid_is_pvd(buffer)) + return 0; + + root_extent = discid_read_le32(buffer + DISCID_PVD_ROOT_DR + DISCID_DR_EXTENT_LE); + root_size = discid_read_le32(buffer + DISCID_PVD_ROOT_DR + DISCID_DR_SIZE_LE); + + if (!root_size || root_size > DISCID_MAX_ROOT_BYTES) + return 0; + + sectors = (root_size + DISCID_USER_BYTES - 1) / DISCID_USER_BYTES; + + for (sector = 0; sector < sectors; ++sector) { + uint32_t offset = 0; + + if (!discid_read_iso(disc, layout, root_extent + sector, buffer)) + return 0; + + while (offset < DISCID_USER_BYTES) { + const uint8_t* record = buffer + offset; + const uint8_t length = record[DISCID_DR_LENGTH]; + uint8_t name_len; + + /* Zero length means the rest of this sector is padding; the next record starts at + the next sector boundary. */ + if (length == 0) + break; + + if (offset + length > DISCID_USER_BYTES) + break; + + name_len = record[DISCID_DR_NAME_LEN]; + + if (name_len && (uint32_t)(DISCID_DR_NAME + name_len) <= length && + discid_name_matches(record + DISCID_DR_NAME, name_len, name)) { + *out_extent = discid_read_le32(record + DISCID_DR_EXTENT_LE); + *out_size = discid_read_le32(record + DISCID_DR_SIZE_LE); + + return (*out_size != 0); + } + + offset += length; + } + } + + return 0; +} + +/* + Pulls the executable name out of SYSTEM.CNF's BOOT line: + + BOOT = cdrom:\SLUS_005.94;1 -> "SLUS_005.94" + + Tolerates every spelling real discs use — "BOOT=" with no spaces, lower-case, "cdrom:" or + "cdrom0:" or neither, forward or back slashes, a leading slash or none, a missing ";1". + "BOOT2" (a PS2 spelling) falls out because the '=' check runs after the key name. +*/ +static int discid_parse_boot_line(const char* text, size_t length, char* out, size_t out_size) { + size_t i; + + for (i = 0; i + 4 < length; ++i) { + const char* p; + size_t written = 0; + + if (discid_tolower(text[i]) != 'b') + continue; + if (strncmp(text + i, "BOOT", 4) != 0 && strncmp(text + i, "boot", 4) != 0) + continue; + + p = text + i + 4; + + while ((size_t)(p - text) < length && (*p == ' ' || *p == '\t')) + ++p; + if ((size_t)(p - text) >= length || *p != '=') + continue; + ++p; + while ((size_t)(p - text) < length && (*p == ' ' || *p == '\t')) + ++p; + + if ((size_t)(p - text) + 7 <= length && + (strncmp(p, "cdrom0:", 7) == 0 || strncmp(p, "CDROM0:", 7) == 0)) { + p += 7; + } else if ((size_t)(p - text) + 6 <= length && + (strncmp(p, "cdrom:", 6) == 0 || strncmp(p, "CDROM:", 6) == 0)) { + p += 6; + } + + while ((size_t)(p - text) < length && (*p == '\\' || *p == '/')) + ++p; + + while ((size_t)(p - text) < length && written + 1 < out_size) { + const char c = *p; + + if (c == ';' || c == '\r' || c == '\n' || c == ' ' || c == '\t' || c == '\0') + break; + + out[written++] = c; + ++p; + } + + out[written] = '\0'; + + if (written) + return 1; + } + + return 0; +} + +/* + "SLUS_005.94" -> "SLUS-00594". Returns 0 for a boot name that is not serial-shaped, which is + a real and expected outcome: homebrew and a few licensed discs boot "PSX.EXE" or "MAIN.EXE". + + The accepted shape is deliberately the SAME one Ps1DiscId.kt accepts — + ^([A-Za-z]{4})[_\-.]?(\d{3})\.?(\d{2}) — so a disc identified through this path and the same + disc identified through the Kotlin path cannot produce two different keys for one game. +*/ +static int discid_normalise(const char* boot_name, char* out, size_t out_size) { + const char* name = boot_name; + const char* p; + size_t i; + + if (out_size < 11) + return 0; + + /* The BOOT line may still carry a directory component on an unusual disc. */ + for (p = boot_name; *p; ++p) { + if (*p == '\\' || *p == '/') + name = p + 1; + } + + for (i = 0; i < 4; ++i) { + if (!discid_is_alpha(name[i])) + return 0; + } + + p = name + 4; + + if (*p == '_' || *p == '-' || *p == '.') + ++p; + + for (i = 0; i < 3; ++i) { + if (!discid_is_digit(p[i])) + return 0; + } + + /* Digits 4 and 5, with the "005.94" decimal point optional. */ + if (p[3] == '.') { + if (!discid_is_digit(p[4]) || !discid_is_digit(p[5])) + return 0; + + out[0] = discid_toupper(name[0]); + out[1] = discid_toupper(name[1]); + out[2] = discid_toupper(name[2]); + out[3] = discid_toupper(name[3]); + out[4] = '-'; + out[5] = p[0]; out[6] = p[1]; out[7] = p[2]; + out[8] = p[4]; out[9] = p[5]; + out[10] = '\0'; + + return 1; + } + + if (!discid_is_digit(p[3]) || !discid_is_digit(p[4])) + return 0; + + out[0] = discid_toupper(name[0]); + out[1] = discid_toupper(name[1]); + out[2] = discid_toupper(name[2]); + out[3] = discid_toupper(name[3]); + out[4] = '-'; + out[5] = p[0]; out[6] = p[1]; out[7] = p[2]; out[8] = p[3]; out[9] = p[4]; + out[10] = '\0'; + + return 1; +} + +/* One full attempt at a single geometry: PVD -> root directory -> SYSTEM.CNF -> BOOT -> serial. */ +static int discid_try_layout(psx_disc_t* disc, const discid_layout_t* layout, + char* out, size_t out_size) { + uint8_t user[DISCID_USER_BYTES]; + char cnf[DISCID_USER_BYTES + 1]; + char boot_name[DISCID_MAX_BOOT_NAME]; + uint32_t extent = 0, size = 0, usable; + + if (!discid_read_iso(disc, layout, DISCID_PVD_SECTOR, user)) + return 0; + + if (!discid_is_pvd(user)) + return 0; + + if (!discid_find_in_root(disc, layout, "SYSTEM.CNF", &extent, &size)) + return 0; + + if (!discid_read_iso(disc, layout, extent, user)) + return 0; + + usable = (size && size < DISCID_USER_BYTES) ? size : DISCID_USER_BYTES; + + memcpy(cnf, user, usable); + cnf[usable] = '\0'; + + if (!discid_parse_boot_line(cnf, usable, boot_name, sizeof(boot_name))) + return 0; + + if (!discid_normalise(boot_name, out, out_size)) { + log_info("discid: BOOT names '%s', which is not a serial", boot_name); + + return 0; + } + + return 1; +} + +/* Appends `lba` to a start-point list unless it is already there. */ +static void discid_push_start(uint32_t* starts, size_t* count, size_t capacity, uint32_t lba) { + size_t i; + + for (i = 0; i < *count; ++i) { + if (starts[i] == lba) + return; + } + + if (*count < capacity) + starts[(*count)++] = lba; +} + +int psx_discid_from_disc(psx_disc_t* disc, char* out, size_t out_size) { + /* Byte offset of the user data inside whatever the container returns, most likely first. */ + static const uint32_t user_offsets[] = { 24, 16, 0 }; + static const size_t offset_count = sizeof(user_offsets) / sizeof(user_offsets[0]); + + uint32_t starts[3]; + size_t start_count = 0, i, j; + uint32_t probe; + int track_lba; + + if (!out || out_size == 0) + return 0; + + out[0] = '\0'; + + if (!disc || !disc->read_sector) + return 0; + + /* + Where to start looking for the volume descriptor. + + A CHD's LBA space includes the 150-sector lead-in, so its track 1 begins at 150 and its + ISO sector 16 is LBA 166. A file-backed raw rip usually begins at 0 — but not always, as + a rip that captured the pregap puts the filesystem an arbitrary few sectors in. So rather + than assuming a base, each start point below is SEARCHED for the descriptor and the base + is derived from where it was actually found. Same policy as the Kotlin extractor, which + has to solve exactly this and does it by scanning the leading sectors. + + The raw bin/iso reader answers 0 for "no track table", so 150 is tried explicitly too. + */ + track_lba = psx_disc_get_track_count(disc) >= 1 ? psx_disc_get_track_lba(disc, 1) : 0; + + if (track_lba > 0) + discid_push_start(starts, &start_count, 3, (uint32_t)track_lba); + discid_push_start(starts, &start_count, 3, 150); + discid_push_start(starts, &start_count, 3, 0); + + /* + Every candidate geometry is carried through to COMPLETION rather than being accepted on + the volume descriptor alone. One that finds a plausible descriptor but no SYSTEM.CNF, or + a BOOT line that is not serial-shaped, is a geometry that guessed wrong — falling through + to the next is what stops a misread becoming a confidently WRONG serial, which would + attach one game's cover, settings and achievements to another. + */ + for (i = 0; i < start_count; ++i) { + for (probe = 0; probe < DISCID_VD_SEARCH_SECTORS; ++probe) { + uint8_t sector[CD_SECTOR_SIZE]; + const uint32_t lba = starts[i] + probe; + int type; + + memset(sector, 0, sizeof(sector)); + + type = psx_disc_read(disc, lba, sector); + + if (type == 0 || type == TS_FAR) + break; + + /* The descriptor cannot sit before ISO sector 16, so neither can the base. */ + if (lba < DISCID_PVD_SECTOR) + continue; + + for (j = 0; j < offset_count; ++j) { + discid_layout_t layout; + + if (!discid_is_pvd(sector + user_offsets[j])) + continue; + + layout.base_lba = lba - DISCID_PVD_SECTOR; + layout.user_offset = user_offsets[j]; + + if (discid_try_layout(disc, &layout, out, out_size)) { + log_info("discid: %s (ISO sector 0 at LBA %u, user data +%u)", + out, layout.base_lba, layout.user_offset); + + return 1; + } + } + } + } + + out[0] = '\0'; + + return 0; +} + +/* + psx_disc_destroy() calls disc->destroy unconditionally, and a failed open may leave it null + (the raw/PBP/CHD paths bail before wiring the vtable up) or set (the cue path installs it + BEFORE it can fail to parse, and its cue_t has to be freed). psx_disc_close() is declared in + disc.h but never implemented, so this split is the only correct teardown. +*/ +static void discid_destroy_disc(psx_disc_t* disc) { + if (!disc) + return; + + if (disc->destroy) + psx_disc_destroy(disc); + else + free(disc); +} + +int psx_discid_from_path(const char* path, char* out, size_t out_size) { + psx_disc_t* disc; + int found; + + if (!out || out_size == 0) + return 0; + + out[0] = '\0'; + + if (!path || !path[0]) + return 0; + + disc = psx_disc_create(); + + if (!disc) + return 0; + + if (psx_disc_open(disc, path) == CDT_ERROR) { + discid_destroy_disc(disc); + + return 0; + } + + found = psx_discid_from_disc(disc, out, out_size); + + discid_destroy_disc(disc); + + return found; +} diff --git a/psx/discid.h b/psx/discid.h new file mode 100644 index 0000000..4980179 --- /dev/null +++ b/psx/discid.h @@ -0,0 +1,49 @@ +#ifndef DISCID_H +#define DISCID_H + +#include + +#include "dev/cdrom/disc.h" + +/* + Disc serial identification, off any container psx_disc_open() can open. + + Every PS1 game disc carries a SYSTEM.CNF in the ISO9660 root directory naming its boot + executable — "BOOT = cdrom:\SLUS_005.94;1" — and that name IS the disc serial. The launcher + reads it in Kotlin (com.armsx2.core.Ps1DiscId) for the containers Java can seek around in + directly (.bin/.cue/.iso/.pbp), which is most of them. + + A .chd is the one it cannot: CHD v5 Huffman-compresses its own hunk map, so there is no way + to reach the filesystem without decompressing, and a hand-rolled decoder that is a bit wrong + returns a plausible WRONG serial rather than failing. The serial keys the cover, the + per-game settings and the RetroAchievements identity, so wrong is worse than absent. + + This walks the disc through the SAME vtable the emulated drive reads through, so whatever + libchdr can boot, this can identify — no second decoder to disagree with the first. + + Geometry is detected rather than assumed: the caller may hand us a CHD (2352-byte sectors, + ISO sector 0 at the track-1 LBA, usually 150), a raw MODE2 rip (2352, ISO sector 0 at LBA 0), + a MODE1 rip (user data 8 bytes earlier) or a plain 2048-byte ISO. See psx_discid_from_disc. + + Serials come back in psx-covers' filename form — "SLUS_005.94" -> "SLUS-00594" — byte-identical + to what Ps1DiscId produces in Kotlin, because the two are alternative routes to the same + identity and a disagreement would silently split a game's settings in half. +*/ + +/* Longest serial is "AAAA-DDDDD" plus a terminator; round up for callers' comfort. */ +#define PSX_DISCID_MAX 16 + +/* + Identifies an ALREADY-OPEN disc. Writes a normalised serial into `out` and returns 1; returns + 0 and leaves `out` an empty string when the disc carries no readable, serial-shaped BOOT line + (an audio CD, a homebrew booting PSX.EXE, a damaged image). +*/ +int psx_discid_from_disc(psx_disc_t* disc, char* out, size_t out_size); + +/* + Opens `path`, identifies it, closes it. Same return contract as psx_discid_from_disc. + Blocking IO — callers on Android dispatch it off the UI thread. +*/ +int psx_discid_from_path(const char* path, char* out, size_t out_size); + +#endif diff --git a/tests/disc_serial.c b/tests/disc_serial.c new file mode 100644 index 0000000..5e67d41 --- /dev/null +++ b/tests/disc_serial.c @@ -0,0 +1,550 @@ +/* + Host gate for disc serial identification (psx/discid.c). + + What this is defending. + + The serial a disc reports about itself is its IDENTITY inside ARMSX: the box art is fetched + by it, per-game settings are keyed on it, play time accrues under it and RetroAchievements + identifies against it. A .chd used to produce no serial at all — the Kotlin extractor cannot + see inside a compressed container — so every CHD in a library fell back to a curated + filename table and, failing that, to a blank placeholder tile. + + psx/discid.c fixes that by reading the disc through the SAME vtable the emulated drive reads + through, so whatever libchdr can boot, this can identify. That makes the risk here a + DIFFERENT one: a serial that is confidently WRONG is worse than one that is absent, because + it silently attaches one game's settings, art and achievements to another. So every case + below is either "this exact disc yields this exact serial" or "this disc yields NOTHING". + + Geometry is the whole difficulty. The same filesystem arrives at these functions in four + shapes, and nothing in the container says which: + + CHD ISO sector 0 at LBA 150 (its LBA space includes the lead-in), user data + 24 bytes into a 2352-byte MODE2 sector + raw MODE2 ISO sector 0 at LBA 0, user data +24 + raw MODE1 ISO sector 0 at LBA 0, user data +16 + plain ISO ISO sector 0 at LBA 0, 2048-byte sectors, user data +0 + + The CHD geometry is the one that matters most and the one no file-backed fixture can produce, + so it is driven here through a synthetic disc vtable that mimics exactly that addressing. + libchdr's own decompression is not re-tested: CHDs already boot, and this deliberately does + not contain a second CHD decoder that could disagree with the one that does. + + Pass a real image path as argv[1] to identify it and print the result — that is the + end-to-end check against an actual .chd, which cannot be synthesised here (writing one needs + chdman). +*/ + +#include +#include +#include + +#include "../psx/discid.h" +#include "../psx/dev/cdrom/disc.h" + +void log_log(int level, const char* file, int line, const char* format, ...) { + (void)level; + (void)file; + (void)line; + (void)format; +} + +static int g_failures = 0; + +static void check(int condition, const char* group, const char* what) { + if (!condition) { + printf(" FAIL [%s] %s\n", group, what); + ++g_failures; + } +} + +static void check_serial(const char* got, int found, const char* expected, + const char* group, const char* what) { + if (expected) { + if (!found || strcmp(got, expected) != 0) { + printf(" FAIL [%s] %s: expected %s, got %s\n", + group, what, expected, found ? got : "(nothing)"); + ++g_failures; + } + } else if (found) { + printf(" FAIL [%s] %s: expected nothing, got %s\n", group, what, got); + ++g_failures; + } +} + +/* ---- synthetic ISO9660 -------------------------------------------------------------------- */ + +#define USER_BYTES 2048 +#define ROOT_SECTOR 22 + +static void put_le32(unsigned char* p, unsigned int value) { + p[0] = (unsigned char)(value & 0xff); + p[1] = (unsigned char)((value >> 8) & 0xff); + p[2] = (unsigned char)((value >> 16) & 0xff); + p[3] = (unsigned char)((value >> 24) & 0xff); +} + +static void put_be32(unsigned char* p, unsigned int value) { + p[0] = (unsigned char)((value >> 24) & 0xff); + p[1] = (unsigned char)((value >> 16) & 0xff); + p[2] = (unsigned char)((value >> 8) & 0xff); + p[3] = (unsigned char)(value & 0xff); +} + +/* One ISO9660 directory record, both-endian as the spec requires, padded to an even length. + Returns how many bytes were written. */ +static unsigned int directory_record(unsigned char* out, unsigned int extent, unsigned int size, + const char* name) { + const unsigned int name_len = (unsigned int)strlen(name); + unsigned int length = 33 + name_len; + + if (length & 1) + ++length; + + memset(out, 0, length); + out[0] = (unsigned char)length; + put_le32(out + 2, extent); + put_be32(out + 6, extent); + put_le32(out + 10, size); + put_be32(out + 14, size); + out[32] = (unsigned char)name_len; + memcpy(out + 33, name, name_len); + + return length; +} + +/* + A whole disc's worth of ISO9660 user data, sparse: only the volume descriptor, the root + directory and SYSTEM.CNF are populated. `boot_line` is written verbatim as SYSTEM.CNF, so a + case can spell it however a real mastering tool would. +*/ +typedef struct { + unsigned char* sectors; /* iso_sector_count * USER_BYTES */ + unsigned int iso_sector_count; +} iso_image_t; + +static void iso_free(iso_image_t* image) { + free(image->sectors); + image->sectors = NULL; + image->iso_sector_count = 0; +} + +static void iso_build(iso_image_t* image, const char* boot_line, unsigned int cnf_sector, + int include_system_cnf) { + unsigned char* pvd; + unsigned char* root; + unsigned int at = 0; + const unsigned int cnf_len = (unsigned int)strlen(boot_line); + + image->iso_sector_count = cnf_sector + 2; + if (image->iso_sector_count < ROOT_SECTOR + 2) + image->iso_sector_count = ROOT_SECTOR + 2; + + image->sectors = calloc(image->iso_sector_count, USER_BYTES); + + pvd = image->sectors + (size_t)16 * USER_BYTES; + pvd[0] = 1; + memcpy(pvd + 1, "CD001", 5); + pvd[6] = 1; + memcpy(pvd + 8, "PLAYSTATION ", 12); + directory_record(pvd + 156, ROOT_SECTOR, USER_BYTES, " "); + + root = image->sectors + (size_t)ROOT_SECTOR * USER_BYTES; + at += directory_record(root + at, ROOT_SECTOR, USER_BYTES, " "); + at += directory_record(root + at, ROOT_SECTOR, USER_BYTES, ""); + if (include_system_cnf) + at += directory_record(root + at, cnf_sector, cnf_len, "SYSTEM.CNF;1"); + directory_record(root + at, cnf_sector + 1, 4096, "OTHER.DAT;1"); + + if (include_system_cnf) + memcpy(image->sectors + (size_t)cnf_sector * USER_BYTES, boot_line, cnf_len); +} + +/* ---- synthetic disc vtable ----------------------------------------------------------------- */ + +/* + The container the emulated drive sees, parameterised by the two things that actually differ + between a CHD and a raw rip: where ISO sector 0 sits in LBA space, and how far into the + returned sector the user data starts. This is how the CHD geometry gets covered without a + CHD: chd_read_sector hands back a full 2352-byte MODE2 sector addressed from LBA 150, and so + does this. +*/ +typedef struct { + const iso_image_t* image; + unsigned int base_lba; + unsigned int user_offset; + int report_track_lba; /* what get_track_lba(1) answers; -1 = no track table */ +} fake_disc_t; + +static int fake_read(void* udata, uint32_t lba, void* buf) { + fake_disc_t* fake = (fake_disc_t*)udata; + unsigned char* out = (unsigned char*)buf; + + memset(out, 0, CD_SECTOR_SIZE); + + if (lba < fake->base_lba) + return TS_PREGAP; + + { + const uint32_t iso_sector = lba - fake->base_lba; + + if (iso_sector >= fake->image->iso_sector_count) + return TS_FAR; + + memcpy(out + fake->user_offset, + fake->image->sectors + (size_t)iso_sector * USER_BYTES, USER_BYTES); + } + + return TS_DATA; +} + +static int fake_query(void* udata, uint32_t lba) { + (void)udata; (void)lba; + return TS_DATA; +} + +static int fake_track_number(void* udata, uint32_t lba) { + (void)udata; (void)lba; + return 1; +} + +static int fake_track_count(void* udata) { + (void)udata; + return 1; +} + +static uint32_t fake_track_lba(void* udata, int track) { + fake_disc_t* fake = (fake_disc_t*)udata; + (void)track; + return (fake->report_track_lba < 0) ? 0u : (uint32_t)fake->report_track_lba; +} + +static void fake_destroy(void* udata) { + (void)udata; +} + +static void fake_disc_init(psx_disc_t* disc, fake_disc_t* fake) { + memset(disc, 0, sizeof(*disc)); + disc->udata = fake; + disc->read_sector = fake_read; + disc->query_sector = fake_query; + disc->get_track_number = fake_track_number; + disc->get_track_count = fake_track_count; + disc->get_track_lba = fake_track_lba; + disc->destroy = fake_destroy; +} + +/* Identify `image` presented with the given geometry. */ +static int identify_fake(const iso_image_t* image, unsigned int base_lba, unsigned int user_offset, + int report_track_lba, char* out, size_t out_size) { + psx_disc_t disc; + fake_disc_t fake; + + fake.image = image; + fake.base_lba = base_lba; + fake.user_offset = user_offset; + fake.report_track_lba = report_track_lba; + + fake_disc_init(&disc, &fake); + + return psx_discid_from_disc(&disc, out, out_size); +} + +/* ---- file-backed fixtures ------------------------------------------------------------------- */ + +/* Writes `image` out as a real file the disc reader will open by extension. `lead_sectors` of + blank space go in front of it, which is how a rip that captured the pregap ahead of the + filesystem is laid out — the volume descriptor is then NOT at file sector 16. */ +static int write_image_file(const iso_image_t* image, const char* path, + unsigned int sector_bytes, unsigned int user_offset, + unsigned int lead_sectors) { + FILE* f = fopen(path, "wb"); + unsigned char* sector; + unsigned int i; + + if (!f) + return 0; + + sector = calloc(1, sector_bytes); + + for (i = 0; i < lead_sectors; ++i) { + if (fwrite(sector, 1, sector_bytes, f) != sector_bytes) { + free(sector); + fclose(f); + return 0; + } + } + + for (i = 0; i < image->iso_sector_count; ++i) { + memset(sector, 0, sector_bytes); + memcpy(sector + user_offset, image->sectors + (size_t)i * USER_BYTES, USER_BYTES); + + if (fwrite(sector, 1, sector_bytes, f) != sector_bytes) { + free(sector); + fclose(f); + return 0; + } + } + + free(sector); + fclose(f); + + return 1; +} + +static void join(char* out, size_t out_size, const char* dir, const char* name) { + snprintf(out, out_size, "%s%s%s", dir, (dir[0] && dir[strlen(dir) - 1] == '/') ? "" : "/", name); +} + +/* ---- cases ----------------------------------------------------------------------------------- */ + +/* + The case this file exists for. A CHD addresses ISO sector 0 at the track-1 LBA — 150 on every + standard disc, because its LBA space includes the lead-in — and returns a full 2352-byte + MODE2 sector. Read it as if it were a raw rip (base 0) and sector 16 is 150 sectors short of + the volume descriptor: no filesystem, no serial, blank tile. +*/ +static void case_chd_geometry(void) { + const char* name = "chd geometry"; + iso_image_t image; + char serial[PSX_DISCID_MAX]; + int found; + + iso_build(&image, "BOOT = cdrom:\\SLUS_006.64;1\r\n", 24, 1); + + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "lead-in offset filesystem (LBA 150, +24)"); + + /* Same disc, but the container does not report a track table. The serial still has to come + out: a reader that only trusts get_track_lba() would go blind here. */ + found = identify_fake(&image, 150, 24, -1, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "LBA 150 with no track table reported"); + + /* MODE1 sectors put the user data 8 bytes earlier. */ + found = identify_fake(&image, 150, 16, 150, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "MODE1 sectors at LBA 150"); + + /* And the raw-rip geometry, for completeness. */ + found = identify_fake(&image, 0, 24, 0, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "raw MODE2 rip at LBA 0"); + + found = identify_fake(&image, 0, 0, 0, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "2048-byte sectors at LBA 0"); + + /* A track table that does not match the filesystem. Trusting it and stopping there is how a + disc silently loses its identity; the search has to fall through to the next start point. */ + found = identify_fake(&image, 0, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, "SLUS-00664", name, "track table says 150, filesystem is at 0"); + + iso_free(&image); +} + +/* SYSTEM.CNF is wherever the mastering tool put it — ISO9660 records the location, it does not + promise proximity. A walk that follows the directory record does not care; a window scan does. */ +static void case_system_cnf_far_into_the_disc(void) { + const char* name = "deep SYSTEM.CNF"; + iso_image_t image; + char serial[PSX_DISCID_MAX]; + int found; + + iso_build(&image, "BOOT = cdrom:\\SCUS_941.63;1\r\n", 9000, 1); + + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, "SCUS-94163", name, "SYSTEM.CNF at ISO sector 9000"); + + iso_free(&image); +} + +static void case_boot_line_spellings(void) { + const char* name = "BOOT spellings"; + static const struct { + const char* line; + const char* expected; + } cases[] = { + { "BOOT=cdrom:\\SLUS_006.64;1\n", "SLUS-00664" }, + { "boot = cdrom:SLUS_006.64;1\n", "SLUS-00664" }, + { "BOOT\t=\tcdrom0:\\SLUS_006.64;1\n", "SLUS-00664" }, + { "BOOT = cdrom:\\\\SLUS_006.64;1\n", "SLUS-00664" }, + { "BOOT = cdrom:/SLUS_006.64;1\n", "SLUS-00664" }, + { "BOOT = cdrom:\\SLUS_006.64\n", "SLUS-00664" }, + { "BOOT = cdrom:\\SLUS_00664;1\n", "SLUS-00664" }, + { "BOOT = cdrom:\\SCES-023.80;1\n", "SCES-02380" }, + { "BOOT = cdrom:\\slus_006.64;1\n", "SLUS-00664" }, + { "TCB = 4\nEVENT = 10\nBOOT = cdrom:\\SCUS_941.63;1\n", "SCUS-94163" }, + /* Not a serial: homebrew and a handful of licensed discs name their executable outright. + There is nothing to key on, and inventing one would be the wrong kind of helpful. */ + { "BOOT = cdrom:\\PSX.EXE;1\n", NULL }, + { "BOOT = cdrom:\\MAIN.EXE;1\n", NULL }, + /* A PS2 spelling has no business identifying a PS1 disc. */ + { "BOOT2 = cdrom0:\\SLUS_200.02;1\n", NULL }, + /* Too few digits to be a serial. */ + { "BOOT = cdrom:\\SLUS_006.6;1\n", NULL }, + }; + const size_t count = sizeof(cases) / sizeof(cases[0]); + size_t i; + + for (i = 0; i < count; ++i) { + iso_image_t image; + char serial[PSX_DISCID_MAX]; + int found; + + iso_build(&image, cases[i].line, 24, 1); + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, cases[i].expected, name, cases[i].line); + iso_free(&image); + } +} + +/* Every one of these has to come back with NOTHING. A wrong serial is worse than no serial. */ +static void case_discs_with_no_identity(void) { + const char* name = "no identity"; + iso_image_t image; + char serial[PSX_DISCID_MAX]; + int found; + + iso_build(&image, "BOOT = cdrom:\\SLUS_006.64;1\r\n", 24, 0); + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, NULL, name, "no SYSTEM.CNF in the root directory"); + check(serial[0] == '\0', name, "a failed identification leaves an empty string"); + iso_free(&image); + + iso_build(&image, "TCB = 4\nEVENT = 10\nSTACK = 801FFFF0\n", 24, 1); + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, NULL, name, "SYSTEM.CNF with no BOOT line"); + iso_free(&image); + + /* No volume descriptor anywhere: an audio CD, or an image whose filesystem is gone. The old + Kotlin extractor falls back to a byte scan here; this deliberately does not, because a + scan over a container the drive can decompress is neither cheap nor more correct. */ + iso_build(&image, "BOOT = cdrom:\\SLUS_006.64;1\r\n", 24, 1); + memset(image.sectors + (size_t)16 * USER_BYTES, 0, USER_BYTES); + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, NULL, name, "no volume descriptor"); + iso_free(&image); + + /* A root directory extent claiming to be enormous is corruption, not a directory. */ + iso_build(&image, "BOOT = cdrom:\\SLUS_006.64;1\r\n", 24, 1); + put_le32(image.sectors + (size_t)16 * USER_BYTES + 156 + 10, 0x7fffffffu); + found = identify_fake(&image, 150, 24, 150, serial, sizeof(serial)); + check_serial(serial, found, NULL, name, "absurd root directory size"); + iso_free(&image); +} + +static void case_bad_arguments(void) { + const char* name = "arguments"; + char serial[PSX_DISCID_MAX]; + + check(psx_discid_from_disc(NULL, serial, sizeof(serial)) == 0, name, "null disc"); + check(psx_discid_from_path(NULL, serial, sizeof(serial)) == 0, name, "null path"); + check(psx_discid_from_path("", serial, sizeof(serial)) == 0, name, "empty path"); + check(psx_discid_from_path("/nonexistent/nowhere.bin", serial, sizeof(serial)) == 0, + name, "a path that is not there"); + check(serial[0] == '\0', name, "a failed open leaves an empty string"); + check(psx_discid_from_disc(NULL, NULL, 0) == 0, name, "null output buffer"); +} + +/* + The same identification, but through psx_disc_open() — i.e. the real reader, opened by + extension, on real files. This is what the JNI entry point actually calls. +*/ +static void case_real_files(const char* dir) { + const char* name = "file-backed"; + iso_image_t image; + char path[1024]; + char serial[PSX_DISCID_MAX]; + + iso_build(&image, "BOOT = cdrom:\\SLUS_005.94;1\r\n", 30, 1); + + join(path, sizeof(path), dir, "mode2.bin"); + if (write_image_file(&image, path, 2352, 24, 0)) { + check_serial(serial, psx_discid_from_path(path, serial, sizeof(serial)), + "SLUS-00594", name, "raw MODE2/2352 .bin"); + remove(path); + } else { + check(0, name, "could not write the .bin fixture"); + } + + join(path, sizeof(path), dir, "mode1.bin"); + if (write_image_file(&image, path, 2352, 16, 0)) { + check_serial(serial, psx_discid_from_path(path, serial, sizeof(serial)), + "SLUS-00594", name, "raw MODE1/2352 .bin"); + remove(path); + } else { + check(0, name, "could not write the MODE1 .bin fixture"); + } + + join(path, sizeof(path), dir, "plain.iso"); + if (write_image_file(&image, path, 2048, 0, 0)) { + check_serial(serial, psx_discid_from_path(path, serial, sizeof(serial)), + "SLUS-00594", name, "plain 2048-byte .iso"); + remove(path); + } else { + check(0, name, "could not write the .iso fixture"); + } + + /* A rip that captured the pregap: the volume descriptor is at file sector 28, not 16. The + raw reader has no track table to ask, so the base has to be found by looking. */ + join(path, sizeof(path), dir, "pregap.bin"); + if (write_image_file(&image, path, 2352, 24, 12)) { + check_serial(serial, psx_discid_from_path(path, serial, sizeof(serial)), + "SLUS-00594", name, "raw .bin with a 12-sector pregap"); + remove(path); + } else { + check(0, name, "could not write the pregap fixture"); + } + + /* An extension the reader does not open at all must fail, not crash. */ + join(path, sizeof(path), dir, "notadisc.txt"); + { + FILE* f = fopen(path, "wb"); + + if (f) { + fwrite("BOOT = cdrom:\\SLUS_006.64;1\n", 1, 28, f); + fclose(f); + check(psx_discid_from_path(path, serial, sizeof(serial)) == 0, + name, "an unsupported extension identifies nothing"); + remove(path); + } + } + + iso_free(&image); +} + +/* ---- entry point -------------------------------------------------------------------------- */ + +/* + `disc_serial [scratch-dir]` runs the gate; `disc_serial --image ` identifies one real + image and prints what it got. The second mode is how an actual .chd gets checked end to end — + synthesising one here would mean vendoring a CHD writer, and the whole point of this module + is that the build contains exactly ONE CHD decoder. +*/ +int main(int argc, char** argv) { + const char* dir; + + if (argc > 2 && strcmp(argv[1], "--image") == 0) { + char serial[PSX_DISCID_MAX]; + const int found = psx_discid_from_path(argv[2], serial, sizeof(serial)); + + printf("%s -> %s\n", argv[2], found ? serial : "NO SERIAL"); + + return found ? 0 : 1; + } + + dir = (argc > 1) ? argv[1] : "."; + + case_chd_geometry(); + case_system_cnf_far_into_the_disc(); + case_boot_line_spellings(); + case_discs_with_no_identity(); + case_bad_arguments(); + case_real_files(dir); + + if (g_failures) { + printf("DISC_SERIAL FAILED (%d)\n", g_failures); + + return 1; + } + + printf("DISC_SERIAL OK\n"); + + return 0; +}