Removed stale files from repo
This commit is contained in:
@@ -1,319 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project Solana for Linux: builds the game from your own disc and plays it.
|
||||
|
||||
project-solana build DISC.iso identify the disc, fetch the build files, build the game (once)
|
||||
project-solana play [options] play it (--upscaled / --native, --fullscreen / --windowed, --vsync)
|
||||
project-solana status what is installed, and whether the 32-bit libraries it needs are there
|
||||
project-solana desktop add Project Solana to your applications menu (and so to Steam)
|
||||
project-solana log print the last game log (rac1_log.txt)
|
||||
|
||||
Like the Windows launcher: nothing from the game is downloaded. The build files (the
|
||||
recompiler, the runtime, the decomp's source) come from the project's GitHub release, the
|
||||
game itself from your disc image. Everything lives in ~/.local/share/project-solana
|
||||
(or $XDG_DATA_HOME/project-solana); `play` remembers your last options.
|
||||
|
||||
Needs: python3 (3.6 or newer), an x86-64 CPU with SSE4.2, and the 32-bit (i386) SDL2 and
|
||||
OpenGL libraries, which Steam installs (SteamOS and any PC with Steam have them)."""
|
||||
import argparse, configparser, hashlib, json, os, shutil, struct, subprocess, sys, time, urllib.request, zipfile
|
||||
|
||||
RELEASE_URL = "https://github.com/OpenRAC/project-solana/releases/download/"
|
||||
GAMES = { # serial (BOOT2 in SYSTEM.CNF): what this version is
|
||||
"SCES_509.16": {"title": "Ratchet & Clank (PAL)", "kit": "rac1", "md5": "d86429e2eca4e1da7d7f1cc656af2574", "exe": "rac1_wrapper"},
|
||||
}
|
||||
OTHER = {"SCUS_971.99": "Ratchet & Clank (NTSC-U)", "SCPS_150.37": "Ratchet & Clank (NTSC-J)",
|
||||
"SCUS_972.68": "Ratchet & Clank: Going Commando (NTSC-U)", "SCES_516.07": "Ratchet & Clank 2 (PAL)",
|
||||
"SCUS_973.53": "Ratchet & Clank: Up Your Arsenal (NTSC-U)", "SCES_524.56": "Ratchet & Clank 3 (PAL)",
|
||||
"SCUS_974.65": "Ratchet: Deadlocked (NTSC-U)", "SCES_532.85": "Ratchet: Gladiator (PAL)"}
|
||||
|
||||
HOME = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "project-solana")
|
||||
INI = os.path.join(HOME, "solana.ini")
|
||||
|
||||
|
||||
def say(*a): print(*a, flush=True)
|
||||
def die(msg): sys.exit("project-solana: " + msg)
|
||||
|
||||
|
||||
def settings():
|
||||
c = configparser.ConfigParser()
|
||||
c.read(INI)
|
||||
for s in ("disc", "play", "build"):
|
||||
if not c.has_section(s): c.add_section(s)
|
||||
return c
|
||||
|
||||
|
||||
def save(c):
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
with open(INI, "w") as f: c.write(f)
|
||||
|
||||
|
||||
# ---- the disc: ISO 9660, SYSTEM.CNF's BOOT2, and that executable's MD5 (as the Windows launcher) ----
|
||||
def iso_root_find(f, root, name):
|
||||
o = 0
|
||||
while o < len(root):
|
||||
ln = root[o]
|
||||
if not ln: o = (o // 2048 + 1) * 2048; continue
|
||||
fl = root[o + 32]
|
||||
fn = root[o + 33:o + 33 + fl].decode("latin-1")
|
||||
if fn.split(";")[0].upper() == name.upper():
|
||||
return struct.unpack_from("<I", root, o + 2)[0], struct.unpack_from("<I", root, o + 10)[0]
|
||||
o += ln
|
||||
return None
|
||||
|
||||
|
||||
def identify(path):
|
||||
"""(serial, md5 of its executable) or raises"""
|
||||
with open(path, "rb") as f:
|
||||
f.seek(16 * 2048); pvd = f.read(2048)
|
||||
if pvd[1:6] != b"CD001": raise ValueError("not a disc image (no ISO 9660 volume)")
|
||||
rlba, rsize = struct.unpack_from("<I", pvd, 158)[0], min(struct.unpack_from("<I", pvd, 166)[0], 1 << 20)
|
||||
f.seek(rlba * 2048); root = f.read(rsize)
|
||||
cnf = iso_root_find(f, root, "SYSTEM.CNF")
|
||||
if not cnf: raise ValueError("not a PlayStation 2 disc (no SYSTEM.CNF)")
|
||||
f.seek(cnf[0] * 2048); text = f.read(cnf[1]).decode("latin-1")
|
||||
boot = [l for l in text.splitlines() if l.strip().upper().startswith("BOOT2")]
|
||||
if not boot or "\\" not in boot[0]: raise ValueError("SYSTEM.CNF names no BOOT2 executable")
|
||||
serial = boot[0].split("\\")[-1].split(";")[0].strip()
|
||||
exe = iso_root_find(f, root, serial)
|
||||
md5 = ""
|
||||
if exe and exe[1] < (64 << 20):
|
||||
f.seek(exe[0] * 2048); md5 = hashlib.md5(f.read(exe[1])).hexdigest()
|
||||
return serial, md5
|
||||
|
||||
|
||||
# ---- downloads ----
|
||||
def fetch(src, dest=None, what="", size=0):
|
||||
"""src: a URL, or a file in a local folder ([build] release_url=/path for offline installs)"""
|
||||
if not src.startswith(("http://", "https://")):
|
||||
if dest: shutil.copyfile(src, dest); return None
|
||||
return open(src, "rb").read()
|
||||
req = urllib.request.Request(src, headers={"User-Agent": "ProjectSolana-Linux/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
total = int(r.headers.get("Content-Length") or size or 0)
|
||||
out = open(dest + ".part", "wb") if dest else None
|
||||
buf, got, last = [], 0, 0
|
||||
while True:
|
||||
b = r.read(1 << 16)
|
||||
if not b: break
|
||||
got += len(b)
|
||||
if out: out.write(b)
|
||||
else: buf.append(b)
|
||||
if what and time.time() - last > 0.5:
|
||||
last = time.time()
|
||||
print("\r %s: %.0f of %.0f MB " % (what, got / 1048576, total / 1048576) if total else "\r %s: %.0f MB " % (what, got / 1048576), end="", flush=True)
|
||||
if what: print("\r %s: done%s" % (what, " " * 30), flush=True)
|
||||
if out: out.close(); os.replace(dest + ".part", dest); return None
|
||||
return b"".join(buf)
|
||||
|
||||
|
||||
def sha256(p):
|
||||
h = hashlib.sha256()
|
||||
with open(p, "rb") as f:
|
||||
for b in iter(lambda: f.read(1 << 20), b""): h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def unzip(zp, dest, what):
|
||||
with zipfile.ZipFile(zp) as z:
|
||||
n = len(z.infolist())
|
||||
for i, info in enumerate(z.infolist()):
|
||||
z.extract(info, dest)
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir(): os.chmod(os.path.join(dest, info.filename), mode)
|
||||
if i % 200 == 0: print("\r unpacking %s: %d of %d files" % (what, i + 1, n), end="", flush=True)
|
||||
print("\r unpacking %s: done%s" % (what, " " * 20), flush=True)
|
||||
|
||||
|
||||
def rmtree(p):
|
||||
if os.path.isdir(p): shutil.rmtree(p, ignore_errors=True)
|
||||
elif os.path.exists(p): os.remove(p)
|
||||
|
||||
|
||||
# ---- the 32-bit libraries the game loads ----
|
||||
LIBDIRS = ["/usr/lib32", "/usr/lib/i386-linux-gnu", "/lib/i386-linux-gnu", "/usr/lib/i686-linux-gnu", "/lib32"]
|
||||
|
||||
|
||||
def have_lib(name):
|
||||
if any(os.path.exists(os.path.join(d, name)) for d in LIBDIRS): return True
|
||||
try: # (Fedora and others keep 32-bit libraries in /usr/lib: ask the linker cache)
|
||||
out = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True).stdout
|
||||
return any(name in l and "x86-64" not in l and "64bit" not in l for l in out.splitlines())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_libs(quiet=False):
|
||||
missing = [n for n in ("libSDL2-2.0.so.0", "libGL.so.1") if not have_lib(n)]
|
||||
if missing and not quiet:
|
||||
say("\nThe game is a 32-bit program and needs these 32-bit libraries: %s" % ", ".join(missing))
|
||||
say("Installing Steam brings them. Otherwise:")
|
||||
say(" Debian, Ubuntu, Mint, Pop!_OS: sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install libsdl2-2.0-0:i386 libgl1:i386 libgl1-mesa-dri:i386")
|
||||
say(" Fedora: sudo dnf install SDL2.i686 mesa-libGL.i686 mesa-dri-drivers.i686")
|
||||
say(" Arch, Manjaro, EndeavourOS: enable [multilib], then sudo pacman -S lib32-sdl2 lib32-mesa")
|
||||
say(" openSUSE: sudo zypper install libSDL2-2_0-0-32bit Mesa-libGL1-32bit")
|
||||
say(" (NVIDIA's own driver: also its 32-bit OpenGL library, e.g. lib32-nvidia-utils on Arch)")
|
||||
return not missing
|
||||
|
||||
|
||||
# ---- build ----
|
||||
def progress_line(line):
|
||||
p = line.split()
|
||||
if len(p) >= 4 and p[0] == "[progress]":
|
||||
ph, i, n = p[1], int(p[2]), max(int(p[3]), 1)
|
||||
label = {"prepare": "preparing", "levels": "translating the levels", "compile": "compiling", "link": "linking", "done": "done"}.get(ph, ph)
|
||||
print("\r %s (%d of %d)%s" % (label, i + (ph == "levels"), n, " " * 10) if ph in ("levels", "compile") else "\r %s%s" % (label, " " * 30), end="", flush=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build(args):
|
||||
iso = os.path.abspath(os.path.expanduser(args.disc))
|
||||
if not os.path.isfile(iso): die("no such file: %s" % iso)
|
||||
say("Disc: %s" % iso)
|
||||
try: serial, md5 = identify(iso)
|
||||
except (OSError, ValueError) as e: die("%s: %s" % (iso, e))
|
||||
g = GAMES.get(serial)
|
||||
if not g: die("%s (%s) is not supported yet." % (OTHER.get(serial, "this game"), serial) if serial in OTHER else "not a game Project Solana knows (%s)" % serial)
|
||||
if g["md5"] and md5 != g["md5"]:
|
||||
die("this is %s, but another version of it (executable MD5 %s): the build is made for %s" % (g["title"], md5, g["md5"]))
|
||||
say("Game: %s" % g["title"])
|
||||
check_libs()
|
||||
c = settings()
|
||||
rel = (args.release_url or c.get("build", "release_url", fallback="") or RELEASE_URL).rstrip("/") + "/"
|
||||
kit = g["kit"]
|
||||
src = (lambda f: rel + kit + "/" + f) if rel.startswith("http") else (lambda f: os.path.join(rel, f))
|
||||
|
||||
say("Checking for the latest build files")
|
||||
man = json.loads(fetch(src(kit + ".json")).decode())
|
||||
tc_dir = os.path.join(HOME, "toolchain")
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
have = open(os.path.join(tc_dir, "version.txt")).read().strip() if os.path.exists(os.path.join(tc_dir, "version.txt")) else ""
|
||||
if have != man["toolchain_version"]:
|
||||
say("Getting the build tools (Python and the zig compiler, %d MB, kept for later builds)" % (int(man["toolchain_linux_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["toolchain_linux_file"])
|
||||
fetch(src(man["toolchain_linux_file"]), zp, "build tools", int(man["toolchain_linux_size"]))
|
||||
if sha256(zp) != man["toolchain_linux_sha256"]: os.remove(zp); die("the build tools did not download correctly (checksum): try again")
|
||||
rmtree(tc_dir); unzip(zp, tc_dir, "build tools"); os.remove(zp)
|
||||
open(os.path.join(tc_dir, "version.txt"), "w").write(man["toolchain_version"])
|
||||
|
||||
gdir = os.path.join(HOME, "games", kit)
|
||||
kv = os.path.join(gdir, "kit_version.txt")
|
||||
old = open(kv).read().strip() if os.path.exists(kv) else ""
|
||||
if old != man["version"] or args.clean:
|
||||
say("Getting the build kit %s (%d MB)" % (man["version"], int(man["kit_size"]) >> 20))
|
||||
zp = os.path.join(HOME, man["kit_file"])
|
||||
fetch(src(man["kit_file"]), zp, "build kit", int(man["kit_size"]))
|
||||
if sha256(zp) != man["kit_sha256"]: os.remove(zp); die("the build kit did not download correctly (checksum): try again")
|
||||
if os.path.isdir(gdir): # everything but the build output (and its saves); a new kit version builds from scratch
|
||||
for e in os.listdir(gdir):
|
||||
if e != "build": rmtree(os.path.join(gdir, e))
|
||||
rmtree(os.path.join(gdir, "build", "linux", "gen")); rmtree(os.path.join(gdir, "build", "linux", "obj"))
|
||||
unzip(zp, gdir, "build kit"); os.remove(zp)
|
||||
|
||||
levels = c.get("build", "levels", fallback="") or man.get("levels") or ",".join(str(i) for i in range(19))
|
||||
cpus = os.cpu_count() or 2
|
||||
try: ram_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1 << 30)
|
||||
except (ValueError, OSError): ram_gb = 8
|
||||
jobs = args.jobs or max(1, min(cpus, int(ram_gb / 1.5)))
|
||||
say("Building the game from your disc (%d jobs): about 15 minutes on a recent 6 to 8 core CPU, longer on older ones" % jobs)
|
||||
env = dict(os.environ, PATH=os.path.join(tc_dir, "zig") + os.pathsep + os.environ.get("PATH", ""), PYTHONUTF8="1",
|
||||
ZIG_GLOBAL_CACHE_DIR=os.path.join(gdir, "zig-cache"), ZIG_LOCAL_CACHE_DIR=os.path.join(gdir, "zig-cache"))
|
||||
py = os.path.join(tc_dir, "python", "bin", "python3")
|
||||
cmd = [py, "-u", "build.py", "--platform", "linux", "--levels", levels, "--iso", iso, "--repo", "decomp", "-j", str(jobs)]
|
||||
log = open(os.path.join(gdir, "build_log.txt"), "w")
|
||||
p = subprocess.Popen(cmd, cwd=gdir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
for line in p.stdout:
|
||||
log.write(line)
|
||||
if not progress_line(line) and ("error" in line.lower() or "failed" in line.lower()): print("\n " + line.rstrip())
|
||||
p.wait(); log.close(); print()
|
||||
if p.returncode: die("the build stopped (see %s)" % os.path.join(gdir, "build_log.txt"))
|
||||
for j in ("build/linux/gen", "build/linux/gen.new", "build/linux/obj", "build/levels", "build/disc", "zig-cache"): rmtree(os.path.join(gdir, j))
|
||||
open(kv, "w").write(man["version"])
|
||||
c.set("disc", kit, iso); c.set("build", "built_" + kit, man["version"]); save(c)
|
||||
say("Built. Ready to play: project-solana play")
|
||||
|
||||
|
||||
# ---- play ----
|
||||
def play(args):
|
||||
c = settings()
|
||||
kit = "rac1"
|
||||
iso = c.get("disc", kit, fallback="")
|
||||
exe = os.path.join(HOME, "games", kit, "build", "linux", GAMES["SCES_509.16"]["exe"])
|
||||
if not os.path.exists(exe) or not iso: die("not built yet: run project-solana build /path/to/your/disc.iso")
|
||||
if not os.path.exists(iso): die("the disc image has moved (%s): build again with its new path" % iso)
|
||||
pc = c["play"]
|
||||
for k, v in (("resolution", args.resolution), ("fullscreen", args.fullscreen), ("vsync", args.vsync)):
|
||||
if v is not None: pc[k] = str(v)
|
||||
save(c)
|
||||
env = dict(os.environ)
|
||||
deck = os.environ.get("SteamDeck") == "1"
|
||||
if pc.get("resolution", "upscaled") == "upscaled":
|
||||
try: # (as the Windows launcher: render at about the screen's height)
|
||||
out = subprocess.run(["xrandr", "--current"], capture_output=True, text=True, timeout=5).stdout
|
||||
h = max(int(l.split("current ")[1].split(" x ")[1].split(",")[0]) for l in out.splitlines() if "current " in l)
|
||||
except Exception:
|
||||
h = 800 if deck else 1080
|
||||
env["UYA_SCALE"] = str(min(4, max(2, -(-h // 448))))
|
||||
full = pc.get("fullscreen", "1" if deck else "0") in ("1", "True", "true")
|
||||
if full: env["UYA_FULLSCREEN"] = "1"
|
||||
if pc.get("vsync", "0") in ("1", "True", "true"): env["UYA_VSYNC"] = "1"
|
||||
if not check_libs(quiet=True): check_libs(); die("install the 32-bit libraries above, then play again")
|
||||
os.chdir(os.path.dirname(exe))
|
||||
os.execve(exe, [exe, "-", iso], env)
|
||||
|
||||
|
||||
def status(args):
|
||||
c = settings()
|
||||
say("Data folder: %s" % HOME)
|
||||
tv = os.path.join(HOME, "toolchain", "version.txt")
|
||||
say("Build tools: %s" % (open(tv).read().strip() if os.path.exists(tv) else "not downloaded"))
|
||||
for kit in ("rac1",):
|
||||
b = c.get("build", "built_" + kit, fallback="")
|
||||
say("%s: %s, disc %s" % (kit, ("built from kit " + b) if b else "not built", c.get("disc", kit, fallback="(none)")))
|
||||
say("32-bit SDL2 and OpenGL: %s" % ("found" if check_libs(quiet=True) else "missing"))
|
||||
if not check_libs(quiet=True): check_libs()
|
||||
|
||||
|
||||
def desktop(args):
|
||||
me = os.path.abspath(sys.argv[0])
|
||||
d = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "applications")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
p = os.path.join(d, "project-solana-rac1.desktop")
|
||||
with open(p, "w") as f:
|
||||
f.write("[Desktop Entry]\nType=Application\nName=Ratchet & Clank (Project Solana)\nComment=Built from your own disc\n"
|
||||
"Exec=\"%s\" play\nTerminal=false\nCategories=Game;\n" % me)
|
||||
say("Added %s" % p)
|
||||
say("Steam: Games > Add a Non-Steam Game to My Library, and pick \"Ratchet & Clank (Project Solana)\".")
|
||||
|
||||
|
||||
def show_log(args):
|
||||
p = os.path.join(HOME, "games", "rac1", "build", "linux", "rac1_log.txt")
|
||||
if not os.path.exists(p): die("no log yet: it is written when the game runs")
|
||||
sys.stdout.write(open(p, errors="replace").read())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(prog="project-solana", description="Build Ratchet & Clank from your own disc, then play it.")
|
||||
sp = ap.add_subparsers(dest="cmd")
|
||||
b = sp.add_parser("build", help="build the game from your disc image")
|
||||
b.add_argument("disc"); b.add_argument("-j", "--jobs", type=int, default=0)
|
||||
b.add_argument("--clean", action="store_true", help="fetch the build kit again and build from scratch")
|
||||
b.add_argument("--release-url", default="", help=argparse.SUPPRESS)
|
||||
p = sp.add_parser("play", help="play (options are remembered)")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--upscaled", dest="resolution", action="store_const", const="upscaled", help="render at about your screen's resolution (default)")
|
||||
g.add_argument("--native", dest="resolution", action="store_const", const="native", help="the PS2's own resolution")
|
||||
f = p.add_mutually_exclusive_group()
|
||||
f.add_argument("--fullscreen", dest="fullscreen", action="store_const", const=1, help="full screen (F11 switches; default on Steam Deck)")
|
||||
f.add_argument("--windowed", dest="fullscreen", action="store_const", const=0)
|
||||
v = p.add_mutually_exclusive_group()
|
||||
v.add_argument("--vsync", dest="vsync", action="store_const", const=1)
|
||||
v.add_argument("--no-vsync", dest="vsync", action="store_const", const=0)
|
||||
sp.add_parser("status", help="what is installed"); sp.add_parser("desktop", help="add it to the applications menu")
|
||||
sp.add_parser("log", help="print the last game log")
|
||||
a = ap.parse_args()
|
||||
if sys.platform != "linux": die("this is the Linux launcher: on Windows, use Project Solana.exe")
|
||||
{"build": build, "play": play, "status": status, "desktop": desktop, "log": show_log}.get(a.cmd, lambda _: ap.print_help())(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"game": "rac1",
|
||||
"version": "2026.10.14.1",
|
||||
"levels": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18",
|
||||
"kit_file": "rac1-build.zip",
|
||||
"kit_size": 5125429,
|
||||
"kit_sha256": "b6919dd6d31d1ae8fac65c2859eaa6c084e98b97af36e3a305b4253da40ba5c5",
|
||||
"toolchain_file": "toolchain-win64.zip",
|
||||
"toolchain_size": 84006129,
|
||||
"toolchain_sha256": "2d56894833ca4066faf5ac008d3fa06aa40e2bb1d1fcb8f5bd26f28a70382b63",
|
||||
"toolchain_version": "py3.12.7-zig0.16.0-rabbitizer1.16.2"
|
||||
}
|
||||
Reference in New Issue
Block a user