diff --git a/launcher/project-solana b/launcher/project-solana deleted file mode 100644 index daf609c..0000000 --- a/launcher/project-solana +++ /dev/null @@ -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(" 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() diff --git a/project-solana-source.zip b/project-solana-source.zip deleted file mode 100644 index 68f4331..0000000 Binary files a/project-solana-source.zip and /dev/null differ diff --git a/rac1.json b/rac1.json deleted file mode 100644 index 377db23..0000000 --- a/rac1.json +++ /dev/null @@ -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" -} \ No newline at end of file