Skip to content

Instantly share code, notes, and snippets.

@chandujr
Created August 26, 2025 12:12
Show Gist options
  • Select an option

  • Save chandujr/45916caa2fe12dad04cc0f4d62e2f01c to your computer and use it in GitHub Desktop.

Select an option

Save chandujr/45916caa2fe12dad04cc0f4d62e2f01c to your computer and use it in GitHub Desktop.
Calculate RetroAchievements hash for PC Engine CD / TurboGrafx-CD games.
#!/usr/bin/env python3
"""
pcecdhash.py - Calculate RetroAchievements hash for PC Engine CD / TurboGrafx-CD games.
Make it executable if needed:
chmod +x pcecdhash.py
Usage:
./pcecdhash.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, hashlib
SECTOR_SIZE = 2352
USERDATA_OFFSET = 16
USERDATA_SIZE = 2048
SECTORS_PER_SECOND = 75
def msf_to_sectors(msf: str) -> int:
"""Convert mm:ss:ff string to sector count."""
mm, ss, ff = [int(x) for x in msf.split(":")]
return mm * 60 * SECTORS_PER_SECOND + ss * SECTORS_PER_SECOND + ff
def read_sector(f, sector_index: int) -> bytes:
"""Read one sector of user data from a bin file."""
f.seek(sector_index * SECTOR_SIZE + USERDATA_OFFSET)
return f.read(USERDATA_SIZE)
def parse_cue_first_data_track(cue_path: str):
"""
Return (bin filename, INDEX 01 sector offset) for the first MODE1/MODE2 track.
"""
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"):
# find INDEX 01 for this track
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 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 128 bytes from sector 1 relative to INDEX 01
header_sector_index = start_sector + 1
f.seek(header_sector_index * SECTOR_SIZE + USERDATA_OFFSET)
header128 = f.read(128)
# Verify magic string
if b"PC Engine CD-ROM SYSTEM" not in header128[32:96]:
raise ValueError("Invalid disc: Missing 'PC Engine CD-ROM SYSTEM' signature.")
# Start building buffer
buffer = bytearray(header128[-22:]) # last 22 bytes = disc title
# Parse boot code info (big endian 3-byte index + 1-byte length)
boot_index = (header128[0] << 16) | (header128[1] << 8) | header128[2]
boot_sectors = header128[3]
# Append boot code sectors
for i in range(boot_sectors):
buffer.extend(read_sector(f, start_sector + boot_index + i))
# MD5 hash
return hashlib.md5(buffer).hexdigest()
def main():
if len(sys.argv) != 2:
print("Usage: pcecdhash.py game.cue")
sys.exit(1)
cue_path = sys.argv[1]
if not os.path.exists(cue_path):
print(f"Error: File not found: {cue_path}")
sys.exit(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