mirror of
https://github.com/ARMSX2/ARMSX1.git
synced 2026-08-24 16:53:35 -07:00
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.
490 lines
15 KiB
C
490 lines
15 KiB
C
/*
|
|
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;
|
|
}
|