Last active
August 29, 2025 15:55
-
-
Save chandujr/8c0b247baceafd466c38b3282118ef28 to your computer and use it in GitHub Desktop.
Calculate RetroAchievements hash for PlayStation games.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| psxhash.py - Calculate RetroAchievements hash for PlayStation games. | |
| Make it executable if needed: | |
| chmod +x psxhash.py | |
| Usage: | |
| ./psxhash.py game.cue | |
| Requirements: | |
| - Python 3.x | |
| - A Redump-style cue/bin set (not trimmed/conversions) | |
| Authors: | |
| Chandu | |
| ChatGPT, 2025 | |
| Countless others | |
| """ | |
| import sys, os, re, hashlib | |
| SECTOR_SIZE = 2352 | |
| USERDATA_OFFSET = 24 # Mode2 Form1 | |
| USERDATA_SIZE = 2048 | |
| SECTORS_PER_SECOND = 75 | |
| def msf_to_sectors(msf: str) -> int: | |
| mm, ss, ff = [int(x) for x in msf.split(":")] | |
| return mm * 60 * SECTORS_PER_SECOND + ss * SECTORS_PER_SECOND + ff | |
| def parse_cue_first_data_track(cue_path: str): | |
| with open(cue_path, "r", encoding="utf-8", errors="ignore") as f: | |
| lines = f.readlines() | |
| current_file = None | |
| for i, line in enumerate(lines): | |
| parts = line.strip().split() | |
| if len(parts) >= 3 and parts[0].upper() == "FILE": | |
| filename = line.split('"')[1] if '"' in line else parts[1] | |
| current_file = os.path.join(os.path.dirname(cue_path), filename) | |
| if len(parts) >= 2 and parts[0].upper() == "TRACK": | |
| mode = parts[2].upper() | |
| if mode.startswith("MODE1") or mode.startswith("MODE2"): | |
| for subline in lines[i+1:]: | |
| subparts = subline.strip().split() | |
| if len(subparts) >= 3 and subparts[0].upper() == "INDEX" and subparts[1] == "01": | |
| return current_file, msf_to_sectors(subparts[2]) | |
| return None, 0 | |
| def read_sector(f, sector_index: int) -> bytes: | |
| f.seek(sector_index * SECTOR_SIZE + USERDATA_OFFSET) | |
| return f.read(USERDATA_SIZE) | |
| def parse_dir_record(record: bytes): | |
| """Parse a single ISO9660 directory record.""" | |
| length = record[0] | |
| if length == 0: | |
| return None | |
| extent = int.from_bytes(record[2:6], "little") | |
| size = int.from_bytes(record[10:14], "little") | |
| name_len = record[32] | |
| name = record[33:33+name_len].decode("ascii", errors="ignore") | |
| return { | |
| "length": length, | |
| "extent": extent, | |
| "size": size, | |
| "name": name | |
| } | |
| def list_dir(f, start_sector, extent, size): | |
| """List ISO9660 directory entries.""" | |
| entries = [] | |
| sectors = (size + USERDATA_SIZE - 1) // USERDATA_SIZE | |
| for i in range(sectors): | |
| data = read_sector(f, start_sector + extent + i) | |
| offset = 0 | |
| while offset < USERDATA_SIZE: | |
| length = data[offset] | |
| if length == 0: | |
| break | |
| rec = parse_dir_record(data[offset:offset+length]) | |
| if rec: | |
| entries.append(rec) | |
| offset += length | |
| return entries | |
| def read_file(f, start_sector, extent, size): | |
| """Read a file given extent and size.""" | |
| buf = bytearray() | |
| sectors = (size + USERDATA_SIZE - 1) // USERDATA_SIZE | |
| for i in range(sectors): | |
| buf.extend(read_sector(f, start_sector + extent + i)) | |
| return bytes(buf[:size]) | |
| def compute_hash(cue_path: str) -> str: | |
| bin_path, start_sector = parse_cue_first_data_track(cue_path) | |
| if not bin_path or not os.path.exists(bin_path): | |
| raise FileNotFoundError("Could not locate data track from cue sheet.") | |
| with open(bin_path, "rb") as f: | |
| # Read Primary Volume Descriptor at sector 16 | |
| pvd = read_sector(f, start_sector + 16) | |
| if pvd[0:1] != b"\x01" or pvd[1:6] != b"CD001": | |
| raise ValueError("Invalid ISO9660 PVD") | |
| root_extent = int.from_bytes(pvd[156+2:156+6], "little") | |
| root_size = int.from_bytes(pvd[156+10:156+14], "little") | |
| # Walk root directory | |
| entries = list_dir(f, start_sector, root_extent, root_size) | |
| # Find SYSTEM.CNF | |
| sysrec = next((e for e in entries if e["name"].upper().startswith("SYSTEM.CNF")), None) | |
| if not sysrec: | |
| raise ValueError("SYSTEM.CNF not found") | |
| system_cnf = read_file(f, start_sector, sysrec["extent"], sysrec["size"]).decode("ascii", errors="ignore") | |
| # Parse BOOT line (handles both cdrom:SLUS_000.67 and cdrom:\SLUS_000.67) | |
| match = re.search(r'BOOT\s*=\s*cdrom:(\\?)([^\s;]+)', system_cnf, re.IGNORECASE) | |
| if not match: | |
| raise ValueError("BOOT= line not found in SYSTEM.CNF") | |
| exe_path = match.group(2).replace("\\", "/") | |
| # Look for executable in root (most PS1 games put it there) | |
| exename = os.path.basename(exe_path) | |
| exerec = next((e for e in entries if e["name"].upper().startswith(exename.upper())), None) | |
| if not exerec: | |
| raise ValueError(f"{exename} not found in root directory") | |
| exe_data = read_file(f, start_sector, exerec["extent"], exerec["size"]) | |
| # Hash = exe path string + exe contents | |
| buffer = exe_path.encode("ascii") + exe_data | |
| return hashlib.md5(buffer).hexdigest() | |
| def main(): | |
| if len(sys.argv) != 2: | |
| print("Usage: psxhash.py game.cue") | |
| sys.exit(1) | |
| cue_path = sys.argv[1] | |
| try: | |
| digest = compute_hash(cue_path) | |
| print(f"RetroAchievements Hash: {digest}") | |
| except Exception as e: | |
| print(f"Error: {e}") | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment