Files
psxrecomp/tools/cycle_testrom/mkdisc.py
Shokunin bfc4056fdc cycle_testrom: build ruler #2 on current master and without mkpsxiso
Ruler #2 no longer built from master:
- game.toml's text_size 0x800 is now rejected by the EXE parser ("a
  truncated text_size must be 4 KiB page-aligned"). Use 0x2000, the
  page-rounded size of the 0x1800-byte text. The icache_miss victim block
  at +0x1000 is now inside the analysed range and compiled too.
- psxrecomp-game writes the text as shards (<exe>_full_NN.c), but
  CMakeLists.txt still named a single <exe>_full.c. Glob the shards and
  fail with the regeneration hint when there are none.

Add mkdisc.py, a pure-Python stand-in for mkpsxiso (Windows-only here):
it writes the ISO9660 boot disc as MODE2/2352 sectors with a correct
EDC/ECC and copies the license area from a disc the developer owns. The
built image stays gitignored.

README: the macOS/Linux flow (headless on both backends), and why
icache_miss reads +16 on the current ROM but +14 in the 2026-06-27 log:
the mmio loops moved the loop top from word 1 to word 0 of its cache
line, so each of its two misses refills one more word. Records the
Beetle values re-measured on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:59:31 -07:00

212 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""mkdisc.py - build a minimal PS1 boot disc (MODE2/2352 BIN + CUE) in pure Python.
A stand-in for mkpsxiso where it is not available (macOS/Linux without the
Windows binary). Writes an ISO9660 volume with SYSTEM.CNF and one boot EXE,
as Mode 2 Form 1 sectors with a correct EDC/ECC (Beetle verifies them).
Sectors 0-15 (the license area) are zero-filled unless --license-from names
a raw MODE2/2352 .bin of a PS1 disc you own; its first 16 sectors are copied
verbatim. That area is copyrighted Sony data: keep the built image local
(disc/*.bin and disc/*.cue are gitignored), never commit or redistribute it.
It is needed even with OpenBIOS: Beetle's CD controller reports a disc
without the license string in sector 4 as unlicensed, and OpenBIOS then
retries GetID forever instead of booting.
Usage (from tools/cycle_testrom, after gen_testrom.py):
python3 mkdisc.py --cnf disc/SYSTEM.CNF --exe cycle_testrom.exe \
--exe-name CYCT_001.01 --volume CYCT00101 --out disc/cyctest.bin \
--license-from "<a PS1 disc you own>.bin"
"""
import argparse, os, struct
SECTOR = 2352
USER = 2048
# ---- EDC / ECC (ECMA-130 Annex A/B; same tables as ecm.c / Mednafen lec) ----
EDC_TABLE = []
for i in range(256):
e = i
for _ in range(8):
e = (e >> 1) ^ (0xD8018001 if e & 1 else 0)
EDC_TABLE.append(e)
def edc(data):
e = 0
for b in data:
e = (e >> 8) ^ EDC_TABLE[(e ^ b) & 0xFF]
return e
ECC_F = [0] * 256
ECC_B = [0] * 256
for i in range(256):
j = ((i << 1) ^ (0x11D if i & 0x80 else 0)) & 0xFF
ECC_F[i] = j
ECC_B[i ^ j] = i
def ecc_block(sec, major_count, minor_count, major_mult, minor_inc, dest):
size = major_count * minor_count
for major in range(major_count):
index = (major >> 1) * major_mult + (major & 1)
a = b = 0
for _ in range(minor_count):
t = sec[0xC + index]
index += minor_inc
if index >= size:
index -= size
a ^= t
b ^= t
a = ECC_F[a]
a = ECC_B[ECC_F[a] ^ b]
sec[dest + major] = a
sec[dest + major + major_count] = a ^ b
def bcd(n):
return ((n // 10) << 4) | (n % 10)
def mode2_form1(lba, user, submode=0x08):
assert len(user) == USER
s = bytearray(SECTOR)
s[0] = 0x00
s[1:11] = b"\xFF" * 10
s[11] = 0x00
a = lba + 150
s[12:16] = bytes([bcd(a // 4500), bcd((a // 75) % 60), bcd(a % 75), 2])
sub = bytes([0, 0, submode, 0])
s[16:20] = sub
s[20:24] = sub
s[24:24 + USER] = user
s[2072:2076] = struct.pack("<I", edc(s[16:2072]))
hdr = bytes(s[12:16])
s[12:16] = b"\0\0\0\0" # mode 2: header excluded from ECC
ecc_block(s, 86, 24, 2, 86, 0x81C) # P
ecc_block(s, 52, 43, 86, 88, 0x8C8) # Q
s[12:16] = hdr
return bytes(s)
# ---- ISO9660 ----
def both16(v): return struct.pack("<H", v) + struct.pack(">H", v)
def both32(v): return struct.pack("<I", v) + struct.pack(">I", v)
def padstr(s, n): return s.encode("ascii").ljust(n, b" ")[:n]
DATE7 = bytes([126, 9, 29, 0, 0, 0, 0]) # 2026-09-29 00:00:00 GMT
DATE17 = b"2026092900000000" + b"\0"
def dirrec(ident, lba, size, is_dir):
ln = 33 + len(ident)
if ln & 1:
ln += 1
r = bytearray(ln)
r[0] = ln
r[2:10] = both32(lba)
r[10:18] = both32(size)
r[18:25] = DATE7
r[25] = 2 if is_dir else 0
r[28:32] = both16(1)
r[32] = len(ident)
r[33:33 + len(ident)] = ident
return bytes(r)
def sectors_for(n):
return max(1, (n + USER - 1) // USER)
def main():
p = argparse.ArgumentParser()
p.add_argument("--cnf", required=True)
p.add_argument("--exe", required=True)
p.add_argument("--exe-name", required=True, help="ISO name, e.g. CYCT_001.01")
p.add_argument("--volume", default="PSXDISC")
p.add_argument("--out", required=True, help="output .bin (a .cue is written beside it)")
p.add_argument("--license-from", help="raw MODE2/2352 .bin of a PS1 disc you own: "
"copy its sectors 0-15 (the license area) verbatim")
p.add_argument("--pad-sectors", type=int, default=300)
a = p.parse_args()
cnf = open(a.cnf, "rb").read()
exe = open(a.exe, "rb").read()
files = sorted([(a.exe_name.upper() + ";1", exe), ("SYSTEM.CNF;1", cnf)])
LBA_PVD, LBA_TERM, LBA_LPT, LBA_MPT, LBA_ROOT = 16, 17, 18, 19, 20
lba = 21
layout = []
for name, data in files:
layout.append((name, lba, data))
lba += sectors_for(len(data))
total = max(lba, a.pad_sectors)
root = bytearray()
root += dirrec(b"\x00", LBA_ROOT, USER, True)
root += dirrec(b"\x01", LBA_ROOT, USER, True)
for name, flba, data in layout:
root += dirrec(name.encode("ascii"), flba, len(data), False)
assert len(root) <= USER
root = bytes(root).ljust(USER, b"\0")
pt_l = struct.pack("<BBIH", 1, 0, LBA_ROOT, 1) + b"\0\0"
pt_m = struct.pack(">BBIH", 1, 0, LBA_ROOT, 1) + b"\0\0"
pvd = bytearray(USER)
pvd[0] = 1
pvd[1:6] = b"CD001"
pvd[6] = 1
pvd[8:40] = padstr("PLAYSTATION", 32)
pvd[40:72] = padstr(a.volume, 32)
pvd[80:88] = both32(total)
pvd[120:124] = both16(1)
pvd[124:128] = both16(1)
pvd[128:132] = both16(USER)
pvd[132:140] = both32(len(pt_l))
pvd[140:144] = struct.pack("<I", LBA_LPT)
pvd[148:152] = struct.pack(">I", LBA_MPT)
pvd[156:190] = dirrec(b"\x00", LBA_ROOT, USER, True)
pvd[190:318] = padstr(a.volume, 128)
pvd[318:446] = padstr("PSXRECOMP", 128)
pvd[446:574] = padstr("MKDISC.PY", 128)
pvd[574:702] = padstr("PLAYSTATION", 128)
pvd[702:739] = padstr("", 37)
pvd[739:776] = padstr("", 37)
pvd[776:813] = padstr("", 37)
for off in (813, 830, 847, 864):
pvd[off:off + 17] = DATE17
pvd[881] = 1
term = bytearray(USER)
term[0] = 0xFF
term[1:6] = b"CD001"
term[6] = 1
out = bytearray()
if a.license_from:
with open(a.license_from, "rb") as f:
lic = f.read(16 * SECTOR)
assert len(lic) == 16 * SECTOR
out += lic
else:
for i in range(16):
out += mode2_form1(i, bytes(USER))
out += mode2_form1(LBA_PVD, bytes(pvd), 0x09)
out += mode2_form1(LBA_TERM, bytes(term), 0x89)
out += mode2_form1(LBA_LPT, pt_l.ljust(USER, b"\0"), 0x89)
out += mode2_form1(LBA_MPT, pt_m.ljust(USER, b"\0"), 0x89)
out += mode2_form1(LBA_ROOT, root, 0x89)
cur = 21
for name, flba, data in layout:
n = sectors_for(len(data))
for k in range(n):
chunk = data[k * USER:(k + 1) * USER].ljust(USER, b"\0")
out += mode2_form1(cur, chunk, 0x89 if k == n - 1 else 0x08)
cur += 1
while cur < total:
out += mode2_form1(cur, bytes(USER))
cur += 1
with open(a.out, "wb") as f:
f.write(out)
cue = os.path.splitext(a.out)[0] + ".cue"
with open(cue, "w") as f:
f.write('FILE "%s" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n'
% os.path.basename(a.out))
print(f"wrote {a.out} ({total} sectors) and {cue}")
if __name__ == "__main__":
main()