Identify .chd discs through the core's own disc reader, so they get cover art

A CHD never produced a serial. Ps1DiscId reads SYSTEM.CNF itself, and it cannot see
inside a compressed container, so every .chd fell through to Ps1TitleSerials -- a
curated filename table of two dozen USA titles behind a USA-only region filter. A CHD
library therefore showed placeholder tiles for everything the table did not list, and
the new Download cover art action could not help because it fetches by serial.

The serial is not only art: RetroAchievements identifies against it, per-game settings
key off it and play time accrues under it. A CHD had none of that either.

Writing a CHD parser in Kotlin would have been wrong twice over. CHD v5 Huffman-
compresses its own hunk map, so it is a few hundred lines of bit-level decoding before
the filesystem is even reachable; and a decoder that is slightly wrong returns a
PLAUSIBLE WRONG serial rather than failing, which silently attaches one game's art,
settings and achievements to another. Relaxing the region filter has the same defect --
a USA serial fetches the wrong regional box art with total confidence.

So the disc goes to the reader that already boots it. psx/discid.c walks the ISO9660
root directory to SYSTEM.CNF and parses its BOOT line through the same psx_disc_t
vtable the emulated drive reads through, which means the build still contains exactly
ONE CHD decoder and there is nothing for a second one to disagree with. Everything
psx_disc_open handles comes along for free: .cue, .bin, .iso, .pbp and .chd.

Geometry is detected, not assumed. The same filesystem arrives in four shapes and
nothing in the container says which: a CHD puts ISO sector 0 at LBA 150 because its
LBA space includes the lead-in, a raw rip puts it at 0 -- or a few sectors in, if the
dump captured the pregap -- and the user data sits 24, 16 or 0 bytes into the sector
depending on MODE2/MODE1/2048. Each candidate is carried through to a serial-shaped
BOOT line before being accepted, so a geometry that finds a plausible volume descriptor
but no SYSTEM.CNF falls through to the next instead of becoming a wrong answer. The
normalisation accepts the same shape Ps1DiscId.kt does, on purpose: the two are routes
to one identity and a disagreement would split a game's settings in half.

Kotlin keeps the fast path. It still reads .bin/.cue/.iso/.pbp itself and only calls
native for containers it cannot open, plus as a last resort when its own walk and byte
scan both came up empty -- which can only add serials where there were none. That does
mean identification can touch native now; it costs nothing, because Pasx2Application
already loads SDL2 + libarmsx on its warm-up thread at process start and MainActivity
calls initializeOnce regardless. Every call is guarded, so a process without the
library degrades to exactly the previous behaviour and says so in serial_probe.log.

Gated by tests/disc_serial.c (make test-disc-serial): the CHD addressing driven through
a synthetic vtable that mimics it, MODE1/MODE2/2048 sectors, pregap rips, a track table
that disagrees with the filesystem, SYSTEM.CNF 9000 sectors in, fourteen BOOT spellings,
and the cases that must yield NOTHING -- PSX.EXE, MAIN.EXE, a PS2 BOOT2 line, a short
serial, a missing SYSTEM.CNF, no volume descriptor, a corrupt root extent. libchdr's
decompression is deliberately not re-tested; it is what already boots these discs.
Pass --image <path> to identify a real image end to end.

One consequence worth knowing: a .chd with no serial in its filename keyed its per-game
settings off the filename stem and now keys off the real serial, so per-game settings,
cheats, custom names, per-game BIOS and the per-game memory card ASSIGNMENT revert to
global once for those entries. Save states are untouched (they key on disc path plus
fingerprint, not serial) and no memory card file is affected.
This commit is contained in:
jpolo1224
2026-08-03 14:04:27 -04:00
parent 78d757a863
commit 189e264bb4
11 changed files with 1269 additions and 24 deletions
+21 -1
View File
@@ -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 <path>` 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) \
@@ -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
@@ -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 ------------------------------------------------------------
@@ -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.
@@ -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
@@ -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, <b>.chd</b>. {@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:
@@ -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) -------------------------------------------------
+31
View File
@@ -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());
+489
View File
@@ -0,0 +1,489 @@
/*
ARMSX disc serial identification. See discid.h for why this exists and what it guarantees.
*/
#include "discid.h"
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+49
View File
@@ -0,0 +1,49 @@
#ifndef DISCID_H
#define DISCID_H
#include <stddef.h>
#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
+550
View File
File diff suppressed because it is too large Load Diff