Files
CosmicScale afb3f02530 Definitive Patch v4.0.0: Replace Sony MBR with OSDMenu MBR; add HOSDMenu and installer improvements
Replace Sony's MBR app with OSDMenu MBR:
- Remove BBN Launcher (BBNL); OSDMenu MBR handles launching games and apps directly
- Improve boot speed
- Launch games up to 6 seconds faster
- Improve Game ID handling for Retro GEM and MemCard Pro 2/SD2PSX
- Remove "Launch Disc" app; disc launching now handled by OSDMenu MBR
- Remove PlayStation 2 Basic Boot Loader (PS2BBL); OSDMenu MBR now natively launches ELFs by holding a gamepad button at startup

Patch HDD-OSD via HOSDMenu and add improvements:
- Support drives larger than 1 TB
- Allow launching homebrew directly from the OSDSYS menu
- See repository for full details: https://github.com/pcm720/OSDMenu

PSBBN installer:
- Show release notes when installing/updating
- Install HOSDMenu alongside PSBBN
- Lower minimum drive size from 200 GB to 32 GB
- Increase max APA partition to 112 GB
- Assign any post-partition unallocated space to the OPL partition
- Advise users to check archive.org or use a VPN if downloads fail

New HOSDMenu installer:
- Add option to install HOSDMenu-only for third-party HDD adapter users
- Allow creating a custom-size POPS partition and assign remaining space to OPL (up to 2 TB)

Game Installer:
- Require PSBBN Definitive Patch v4.0.0 and above or HOSDMenu-only
- Support HOSDMenu-only setups
- Update OSDMenu MBR and HOSDMenu when newer versions are available
- Update Navigator Menu with shortcuts to selected game launcher, HOSDMenu and wLaunchELF
- Update HOSDMenu config to show installed homebrew apps in OSDSYS menu
- Convert PS1 BIN/CUE files to VCD and PS2 BIN/CUE file to ISO
- Copy only valid game and homebrew files when syncing/adding
- Make rsync ignore Windows `:Zone.Identifier` metadata files to prevent sync failures
- Use PFS Fuse and rsync to install PS1 games
- Capitalize lowercase .VCD extensions to ensure compatibility with POPStarter
- Relocate `OPNPS2LD.ELF` and `nhddl.elf` to `__system/launcher` and `POPSTARTER.ELF` to `__common/POPS` from exFAT

list-builder.py
- Update to scan PFS __.POPS for .VCD files instead of local POPS folder

PS2 Linux Installer:
- Update OSDMenu MBR config so Linux can be booted by holding CIRCLE at power-on instead of interrupting PSBBN startup

NHDDL
- Update to version 1.2.0

Setup.sh and flake.nix
- Add bchunk to dependences

PSBBN Launcher for Windows:
- Reduce minimum disk capacity from 200 GB to 32 GB
- Improve user prompts

General:
- Add support for ARM64 systems
- Replace BOOT.ELF with the SAS-compliant wLaunchELF_ISR version 4.43x_isr-bb13043
- Remove PS1VModeNeg.elf
- Change locale from en_US.UTF-8 to C.UTF-8 to avoid missing-locale failures and ensure script output and logs remain in English and prevent related failures
- Improved mounting and unmouting APA partitions
- Fix bugs
- Add software licences
2025-11-14 16:25:55 +00:00

379 lines
13 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
#
# binmerge
#
# Takes a cue sheet with multiple binary track files and merges them together,
# generating a corrected cue sheet in the process.
#
# Copyright (C) 2024 Chris Putnam
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
#
# Please report any bugs on GitHub: https://github.com/putnam/binmerge
#
#
import argparse, re, os, subprocess, sys, textwrap, traceback
VERBOSE = False
VERSION_STRING = "1.0.3"
def print_license():
print(textwrap.dedent(f"""
binmerge {VERSION_STRING}
Copyright (C) 2024 Chris Putnam
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Source code available at: https://github.com/putnam/binmerge
"""))
def d(s):
if VERBOSE:
print("[DEBUG]\t%s" % s)
def e(s):
print("[ERROR]\t%s" % s)
def p(s):
print("[INFO]\t%s" % s)
class Track:
globalBlocksize = None
def __init__(self, num, track_type):
self.num = num
self.indexes = []
self.track_type = track_type
self.sectors = None
self.file_offset = None
# All possible blocksize types. You cannot mix types on a disc, so we will use the first one we see and lock it in.
#
# AUDIO Audio/Music (2352)
# CDG Karaoke CD+G (2448)
# MODE1/2048 CDROM Mode1 Data (cooked)
# MODE1/2352 CDROM Mode1 Data (raw)
# MODE2/2336 CDROM-XA Mode2 Data
# MODE2/2352 CDROM-XA Mode2 Data
# CDI/2336 CDI Mode2 Data
# CDI/2352 CDI Mode2 Data
if not Track.globalBlocksize:
if track_type in ['AUDIO', 'MODE1/2352', 'MODE2/2352', 'CDI/2352']:
Track.globalBlocksize = 2352
elif track_type == 'CDG':
Track.globalBlocksize = 2448
elif track_type == 'MODE1/2048':
Track.globalBlocksize = 2048
elif track_type in ['MODE2/2336', 'CDI/2336']:
Track.globalBlocksize = 2336
d("Locked blocksize to %d" % Track.globalBlocksize)
class File:
def __init__(self, filename):
self.filename = filename
self.tracks = []
self.size = os.path.getsize(filename)
class ZeroBinFilesException(Exception):
pass
class BinFilesMissingException(Exception):
pass
def read_cue_file(cue_path):
files = []
this_track = None
this_file = None
bin_files_missing = False
f = open(cue_path, 'r')
for line in f:
m = re.search(r'FILE "?(.*?)"? BINARY', line)
if m:
this_path = os.path.join(os.path.dirname(cue_path), m.group(1))
if not (os.path.isfile(this_path) or os.access(this_path, os.R_OK)):
e("Bin file not found or not readable: %s" % this_path)
bin_files_missing = True
else:
this_file = File(this_path)
files.append(this_file)
continue
m = re.search(r'TRACK (\d+) ([^\s]*)', line)
if m and this_file:
this_track = Track(int(m.group(1)), m.group(2))
this_file.tracks.append(this_track)
continue
m = re.search(r'INDEX (\d+) (\d+:\d+:\d+)', line)
if m and this_track:
this_track.indexes.append({'id': int(m.group(1)), 'stamp': m.group(2), 'file_offset':cuestamp_to_sectors(m.group(2))})
continue
if bin_files_missing:
raise BinFilesMissingException
if not len(files):
raise ZeroBinFilesException
if len(files) == 1:
# only 1 file, assume splitting, calc sectors of each
next_item_offset = files[0].size // Track.globalBlocksize
for t in reversed(files[0].tracks):
t.sectors = next_item_offset - t.indexes[0]["file_offset"]
next_item_offset = t.indexes[0]["file_offset"]
for f in files:
d("-- File --")
d("Filename: %s" % f.filename)
d("Size: %d" % f.size)
d("Tracks:")
for t in f.tracks:
d(" -- Track --")
d(" Num: %d" % t.num)
d(" Type: %s" % t.track_type)
if t.sectors: d(" Sectors: %s" % t.sectors)
d(" Indexes: %s" % repr(t.indexes))
return files
def sectors_to_cuestamp(sectors):
# 75 sectors per second
minutes = sectors / 4500
fields = sectors % 4500
seconds = fields / 75
fields = sectors % 75
return '%02d:%02d:%02d' % (minutes, seconds, fields)
def cuestamp_to_sectors(stamp):
# 75 sectors per second
m = re.match(r"(\d+):(\d+):(\d+)", stamp)
minutes = int(m.group(1))
seconds = int(m.group(2))
fields = int(m.group(3))
return fields + (seconds * 75) + (minutes * 60 * 75)
# Generates track filename based on redump naming convention
# (Note: prefix should NEVER contain a path; this function deals only in filenames)
def track_filename(prefix, track_num, track_count):
# Redump is strangely inconsistent in their datfiles and cuesheets when it
# comes to track numbers. The naming convention currently seems to be:
# If there is exactly one track: "" (nothing)
# If there are less than 10 tracks: "Track 1", "Track 2", etc.
# If there are more than 10 tracks: "Track 01", "Track 02", etc.
#
# It'd be nice if it were consistently %02d!
#
# TODO: Migrate everything to pathlib
if track_count == 1:
return "%s.bin" % (prefix)
if track_count > 9:
return "%s (Track %02d).bin" % (prefix, track_num)
return "%s (Track %d).bin" % (prefix, track_num)
# Generates a 'merged' cuesheet, that is, one bin file with tracks indexed within.
def gen_merged_cuesheet(basename, files):
cuesheet = 'FILE "%s.bin" BINARY\n' % basename
# One sector is (BLOCKSIZE) bytes
sector_pos = 0
for f in files:
for t in f.tracks:
cuesheet += ' TRACK %02d %s\n' % (t.num, t.track_type)
for i in t.indexes:
cuesheet += ' INDEX %02d %s\n' % (i['id'], sectors_to_cuestamp(sector_pos + i['file_offset']))
sector_pos += f.size / Track.globalBlocksize
return cuesheet
# Generates a 'split' cuesheet, that is, with one bin file for every track.
def gen_split_cuesheet(basename, merged_file):
cuesheet = ""
for t in merged_file.tracks:
track_fn = track_filename(basename, t.num, len(merged_file.tracks))
cuesheet += 'FILE "%s" BINARY\n' % track_fn
cuesheet += ' TRACK %02d %s\n' % (t.num, t.track_type)
for i in t.indexes:
sector_pos = i['file_offset'] - t.indexes[0]['file_offset']
cuesheet += ' INDEX %02d %s\n' % (i['id'], sectors_to_cuestamp(sector_pos))
return cuesheet
# Merges files together to new file `merged_filename`, in listed order.
def merge_files(merged_filename, files):
if os.path.exists(merged_filename):
e('Target merged bin path already exists: %s' % merged_filename)
return False
# cat is actually a bit faster, but this is multi-platform and no special-casing
chunksize = 1024 * 1024
with open(merged_filename, 'wb') as outfile:
for f in files:
with open(f.filename, 'rb') as infile:
while True:
chunk = infile.read(chunksize)
if not chunk:
break
outfile.write(chunk)
return True
# Writes each track in a File to a new file
def split_files(new_basename, merged_file, outdir):
with open(merged_file.filename, 'rb') as infile:
# Check all tracks for potential file-clobbering first before writing anything
for t in merged_file.tracks:
out_basename = track_filename(new_basename, t.num, len(merged_file.tracks))
out_path = os.path.join(outdir, out_basename)
if os.path.exists(out_path):
e('Target bin path already exists: %s' % out_path)
return False
for t in merged_file.tracks:
chunksize = 1024 * 1024
out_basename = track_filename(new_basename, t.num, len(merged_file.tracks))
out_path = os.path.join(outdir, out_basename)
tracksize = t.sectors * Track.globalBlocksize
written = 0
with open(out_path, 'wb') as outfile:
d('Writing bin file: %s' % out_path)
while True:
if chunksize + written > tracksize:
chunksize = tracksize - written
chunk = infile.read(chunksize)
outfile.write(chunk)
written += chunksize
if written == tracksize:
break
return True
def main():
parser = argparse.ArgumentParser(description="Using a cuesheet, merges numerous bin files into a single bin file and produces a new cuesheet with corrected offsets. Works great with Redump. Supports all block modes, but only binary track types.")
parser.add_argument('cuefile', help='path to current cue file (bin files are expected in the same dir)')
parser.add_argument('basename', help='name (without extension) for your new bin/cue files; will be placed in the same dir as input unless --outdir is specified')
# argparse is bad. make -l work like -h (no args required)
class licenseAction(argparse._StoreTrueAction):
def __call__(self, parser, namespace, values, option_string=None):
print_license()
parser.exit()
parser.add_argument('-l', '--license', default=False, help='prints license info and exits', action=licenseAction)
parser.add_argument('-v', '--verbose', action='store_true', help='print more verbose messages')
parser.add_argument('-s', '--split', help='reverses operation, splitting merged files back to individual tracks', required=False, action='store_true')
parser.add_argument('-o', '--outdir', default=False, help='output directory. defaults to the same directory as source cue. directory will be created (recursively) if needed.')
args = parser.parse_args()
if args.verbose:
global VERBOSE
VERBOSE = True
# Resolve relative paths and cwd
cuefile = os.path.abspath(args.cuefile)
if not os.path.exists(cuefile):
e("Cue file does not exist: %s" % cuefile)
return False
if not os.access(cuefile, os.R_OK):
e("Cue file is not readable: %s" % cuefile)
return False
if args.outdir:
outdir = os.path.abspath(args.outdir)
p("Output directory: %s" % outdir)
if not os.path.exists(outdir):
try:
p("Output directory did not exist; creating it.")
os.makedirs(outdir)
except:
e("Could not create output directory (permissions?)")
traceback.print_exc()
return False
else:
outdir = os.path.dirname(cuefile)
p("Output directory: %s" % outdir)
if not (os.path.exists(outdir) or os.path.isdir(outdir)):
e("Output directory does not exist or is not a directory: %s" % outdir)
return False
if not os.access(outdir, os.W_OK):
e("Output directory is not writable: %s" % outdir)
return False
p("Opening cue: %s" % cuefile)
try:
cue_map = read_cue_file(cuefile)
except BinFilesMissingException:
e("One or more bin files were missing on disk. Aborting.")
return False
except ZeroBinFilesException:
e("Unable to parse any bin files in the cuesheet. Is it empty?")
return False
except Exception as exc:
e("Error parsing cuesheet. Is it valid?")
traceback.print_exc()
return False
if args.split:
cuesheet = gen_split_cuesheet(args.basename, cue_map[0])
else:
cuesheet = gen_merged_cuesheet(args.basename, cue_map)
if not os.path.exists(args.outdir):
e("Output dir does not exist")
return False
new_cue_fn = os.path.join(outdir, args.basename+'.cue')
if os.path.exists(new_cue_fn):
e("Output cue file already exists. Quitting. Path: %s" % new_cue_fn)
return False
if args.split:
p("Splitting files...")
if split_files(args.basename, cue_map[0], outdir):
p("Wrote %d bin files" % len(cue_map[0].tracks))
else:
e("Unable to split bin files.")
return False
else:
p("Merging %d tracks..." % len(cue_map))
out_path = os.path.join(outdir, args.basename+'.bin')
if merge_files(out_path, cue_map):
p("Wrote %s" % out_path)
else:
e("Unable to merge bin files.")
return False
with open(new_cue_fn, 'w', newline='\r\n') as f:
f.write(cuesheet)
p("Wrote new cue: %s" % new_cue_fn)
return True
if not main():
sys.exit(1)