"""Read identifying metadata from PSP .iso images (ISO9660 + PARAM.SFO). PSP UMD dumps are ISO9660 images containing ``PSP_GAME/PARAM.SFO`` (games) or ``UMD_VIDEO/PARAM.SFO`` (videos). PARAM.SFO is a key/value blob that carries ``DISC_ID`` (the catalogue serial, e.g. ``ULUS-10027``) and ``TITLE``. """ import re import struct class IsoError(Exception): """Raised when an ISO cannot be parsed or lacks the expected files.""" SECTOR_SIZE = 2048 PVD_SECTOR = 16 ROOT_RECORD_OFFSET = 156 def _read_sector(f, lba): f.seek(lba * SECTOR_SIZE) data = f.read(SECTOR_SIZE) if len(data) < SECTOR_SIZE: raise IsoError(f"short read at LBA {lba}") return data def _parse_dir_record(buf, offset): """Parse one ISO9660 directory record at ``offset`` in ``buf``. Returns ``(entry, next_offset)`` where ``entry`` is ``(name, is_dir, extent_lba, size)`` or ``None`` when ``offset`` points at padding. """ if offset >= len(buf): return None, offset length = buf[offset] if length == 0: return None, offset + 1 if offset + 33 > len(buf) or offset + length > len(buf): raise IsoError("directory record overruns buffer") name_len = buf[offset + 32] flags = buf[offset + 25] extent = struct.unpack_from(" size: raise IsoError("bad PARAM.SFO index table") fields = {} for i in range(count): off = index_start + i * 16 key_off, fmt, param_len, _max_len, data_off = struct.unpack_from(" size: continue raw = data[value_start : value_start + param_len] if fmt in (0x0004, 0x0404) and param_len == 4: # uint32 value = struct.unpack(" ULUS-10021).""" if not value: return value s = str(value).strip().upper() m = _SERIAL_RE.match(s) if m: return f"{m.group(1)}-{m.group(2)}{m.group(3)}" return s def looks_like_serial(value): """True if a value looks like a real PSP catalogue number (e.g. ULES-00011).""" if not value: return False return bool(re.match(r"^[A-Za-z]{3,4}-?\d{3,5}(-.*)?$", str(value).strip())) def read_identity(path): """Return ``{"serial": ..., "title": ..., "sfo": {...}, "kind": ...}``. ``kind`` is ``"game"`` when the disc has ``PSP_GAME/PARAM.SFO`` or ``"video"`` when it has ``UMD_VIDEO/PARAM.SFO``. """ sfo = None kind = None with open(path, "rb") as f: for dir_name in ("PSP_GAME", "UMD_VIDEO"): try: sfo = parse_sfo(_read_sfo_file(f, dir_name)) kind = "game" if dir_name == "PSP_GAME" else "video" break except IsoError: continue if sfo is None: raise IsoError("no PARAM.SFO found (tried PSP_GAME and UMD_VIDEO)") title = sfo.get("TITLE") or sfo.get("TITLE_0") or sfo.get("TITLE_00") return { "serial": normalize_serial(sfo.get("DISC_ID")), "title": title, "sfo": sfo, "kind": kind, }