Removed stale files from repo

This commit is contained in:
vetusmagnus
2026-10-14 11:44:40 -04:00
parent 622217e133
commit 556871c3bf
3 changed files with 0 additions and 331 deletions
-319
View File
@@ -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()