Skip to content

Instantly share code, notes, and snippets.

@chandujr
Last active August 29, 2025 15:55
Show Gist options
  • Select an option

  • Save chandujr/8c0b247baceafd466c38b3282118ef28 to your computer and use it in GitHub Desktop.

Select an option

Save chandujr/8c0b247baceafd466c38b3282118ef28 to your computer and use it in GitHub Desktop.
Calculate RetroAchievements hash for PlayStation games.
#!/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