#!/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)
  project-solana report             write one file with everything needed for a bug report

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)", "id": "rac1", "region": "pal", "kit": "rac1", "md5": "d86429e2eca4e1da7d7f1cc656af2574", "exe": "rac1_wrapper"},
    "SCUS_971.99": {"title": "Ratchet & Clank (NTSC-U)", "id": "rac1us", "region": "us", "kit": "rac1", "md5": "0c081d4e0353b419d95e5f95eb0ebacb", "exe": "rac1_wrapper"},
}
BY_ID = {g["id"]: g for g in GAMES.values()}
OTHER = {"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)"}

VERSION = "1.6"
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")
LAUNCHER_LOG = os.path.join(HOME, "launcher_log.txt")


def log(text):
    """everything the launcher says also goes to launcher_log.txt (started from Steam or a menu,
    there is no terminal to read it in)"""
    try:
        os.makedirs(HOME, exist_ok=True)
        if os.path.exists(LAUNCHER_LOG) and os.path.getsize(LAUNCHER_LOG) > (1 << 20):
            os.replace(LAUNCHER_LOG, LAUNCHER_LOG + ".old")
        with open(LAUNCHER_LOG, "a") as f: f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + text + "\n")
    except OSError:
        pass


def notify(text):
    """a desktop notification, when there is no terminal to print to"""
    if sys.stdout.isatty(): return
    for cmd in (["notify-send", "-a", "Project Solana", "Project Solana", text], ["zenity", "--error", "--title=Project Solana", "--text=" + text],
                ["kdialog", "--title", "Project Solana", "--error", text]):
        if shutil.which(cmd[0]):
            try: subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); return
            except OSError: pass


def say(*a):
    t = " ".join(str(x) for x in a)
    print(t, flush=True); log(t)


def die(msg):
    log("error: " + msg)
    notify(msg + "\n\n(project-solana report writes a file to send with a bug report)")
    sys.exit("project-solana: " + msg)


def out_dir(g):
    """the game's build output (and its saves): build/linux, build/linux_us"""
    return os.path.join(HOME, "games", g["kit"], "build", "linux" if g["region"] == "pal" else "linux_" + g["region"])


def chosen(c, want=None):
    """the game to play: --game, else the one built last (else PAL)"""
    if want:
        g = BY_ID.get(want) or BY_ID.get("rac1" + want) or (BY_ID["rac1"] if want == "pal" else None)
        if not g: die("no game %s: pal or us" % want)
        return g
    return BY_ID.get(c.get("play", "game", fallback="rac1"), BY_ID["rac1"])


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 ----
# Distributions keep 32-bit libraries in different places (Debian: /usr/lib/i386-linux-gnu, Arch and
# SteamOS: /usr/lib32, Fedora: /usr/lib, where Arch keeps its 64-bit ones), so a file is only counted
# when it really is 32-bit x86. SteamOS has no 32-bit SDL2 of its own (and its system is read-only),
# but Steam carries one in its runtime: that copy is used when the system has none.
SYS_DIRS = ["/usr/lib32", "/usr/lib/i386-linux-gnu", "/lib/i386-linux-gnu", "/usr/lib/i686-linux-gnu", "/lib32", "/usr/lib", "/lib",
            "/usr/local/lib32", "/usr/local/lib"]
STEAM_ROOTS = ["~/.local/share/Steam", "~/.steam/steam", "~/.steam/root", "~/.var/app/com.valvesoftware.Steam/.local/share/Steam",
               "~/.var/app/com.valvesoftware.Steam/data/Steam", "~/snap/steam/common/.local/share/Steam"]
STEAM_SUBDIRS = ["ubuntu12_32/steam-runtime/usr/lib/i386-linux-gnu", "ubuntu12_32/steam-runtime/lib/i386-linux-gnu",
                 "ubuntu12_32/steam-runtime/pinned_libs_32", "ubuntu12_32/steam-runtime/i386/usr/lib/i386-linux-gnu",
                 "ubuntu12_32/steam-runtime/i386/lib/i386-linux-gnu",
                 # the runtime Steam installs as a tool ("Steam Linux Runtime 1.0 (scout)")
                 "steamapps/common/SteamLinuxRuntime/steam-runtime/usr/lib/i386-linux-gnu",
                 "steamapps/common/SteamLinuxRuntime/steam-runtime/lib/i386-linux-gnu",
                 "steamapps/common/SteamLinuxRuntime/steam-runtime/pinned_libs_32"]
SDL = "libSDL2-2.0.so.0"


def elf32(path):
    """a 32-bit x86 ELF file (symlinks followed)"""
    try:
        with open(path, "rb") as f: h = f.read(20)
        return h[:4] == b"\x7fELF" and h[4] == 1 and struct.unpack_from("<H", h, 18)[0] == 3
    except OSError:
        return False


def ldconfig_paths(name):
    exe = shutil.which("ldconfig") or next((p for p in ("/sbin/ldconfig", "/usr/sbin/ldconfig") if os.path.exists(p)), None)
    if not exe: return []
    try: out = subprocess.run([exe, "-p"], capture_output=True, text=True, timeout=10).stdout
    except (OSError, subprocess.SubprocessError): return []
    return [l.split("=>", 1)[1].strip() for l in out.splitlines() if l.strip().startswith(name + " ") and "=>" in l]


def lib_candidates(name):
    seen, out = set(), []
    for p in [os.path.join(d, name) for d in SYS_DIRS] + ldconfig_paths(name):
        r = os.path.realpath(p)
        if os.path.exists(p) and r not in seen: seen.add(r); out.append(p)
    return out


def find32(name):
    return next((p for p in lib_candidates(name) if elf32(p)), None)


def steam_sdl():
    """Steam's own 32-bit SDL2 (its runtime), if Steam is installed and has run once"""
    for root in STEAM_ROOTS:
        for sub in STEAM_SUBDIRS:
            p = os.path.join(os.path.expanduser(root), sub, SDL)
            if elf32(p): return p
    for root in STEAM_ROOTS:   # (another runtime layout: look for it)
        tops = [os.path.join(os.path.expanduser(root), "ubuntu12_32")]
        common = os.path.join(os.path.expanduser(root), "steamapps", "common")
        if os.path.isdir(common): tops += [os.path.join(common, d) for d in sorted(os.listdir(common)) if d.startswith("SteamLinuxRuntime")]
        for top in tops:
            for d, dirs, files in os.walk(top):
                if SDL in files and elf32(os.path.join(d, SDL)): return os.path.join(d, SDL)
                if d.count(os.sep) - top.count(os.sep) > 5: dirs[:] = []
    return None


def find_sdl():
    """(path, from Steam) of a 32-bit SDL2, or (None, False)"""
    p = find32(SDL)
    if p: return p, False
    p = steam_sdl()
    return (p, True) if p else (None, False)


def check_libs(quiet=False):
    missing = []
    if not find32("ld-linux.so.2"): missing.append("the 32-bit C library (ld-linux.so.2)")
    if not find_sdl()[0]: missing.append(SDL)
    if not find32("libGL.so.1"): missing.append("libGL.so.1")
    if missing and not quiet:
        say("\nThe game is a 32-bit program and needs these 32-bit libraries: %s" % ", ".join(missing))
        if missing == [SDL]:
            say("If Steam is installed, start it once: the game can use the copy of SDL2 that comes with Steam.")
        say("To install them:")
        say("  Debian, Ubuntu, Mint, Pop!_OS:  sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install libc6:i386 libsdl2-2.0-0:i386 libgl1:i386 libgl1-mesa-dri:i386")
        say("  Fedora, Nobara, Bazzite:        sudo dnf install glibc.i686 SDL2.i686 mesa-libGL.i686 mesa-dri-drivers.i686")
        say("  Arch, Manjaro, EndeavourOS:      enable [multilib], then sudo pacman -S lib32-glibc lib32-sdl2 lib32-mesa")
        say("  openSUSE:                        sudo zypper install glibc-32bit 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


def is_steamos():
    try: return any(l.strip() in ("ID=steamos", 'ID="steamos"') for l in open("/etc/os-release"))
    except OSError: return False


def sdl_env(env):
    """when the SDL2 is Steam's: a folder holding only a link to it goes first on the library path
    (not Steam's whole runtime: its other libraries would replace the system's)"""
    p, steam = find_sdl()
    if not steam: return
    d = os.path.join(HOME, "lib32")
    os.makedirs(d, exist_ok=True)
    link = os.path.join(d, SDL)
    if os.path.islink(link) or os.path.exists(link): os.remove(link)
    os.symlink(p, link)
    env["LD_LIBRARY_PATH"] = d + (os.pathsep + env["LD_LIBRARY_PATH"] if env.get("LD_LIBRARY_PATH") else "")
    log("using Steam's SDL2: %s" % p)


# ---- build ----
OPEN_FILES_NEEDED = 16384   # the link opens every object file at once (about 5,700 for all levels)


def raise_open_files():
    """Linux allows 1,024 open files per program by default (the soft limit); a program may raise
    its own up to the hard limit (usually 524,288) without root. The build inherits it."""
    try:
        import resource
        soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
        want = hard if hard == resource.RLIM_INFINITY else min(hard, max(soft, 1 << 20))
        if soft != resource.RLIM_INFINITY and soft < want:
            resource.setrlimit(resource.RLIMIT_NOFILE, (want, hard))
        soft2 = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
        log("open files limit: %s (was %s, hard %s)" % (soft2, soft, hard))
        if soft2 != resource.RLIM_INFINITY and soft2 < OPEN_FILES_NEEDED:
            say("Warning: this system lets a program open only %d files at once; linking the game needs about %d." % (soft2, OPEN_FILES_NEEDED))
            say("If the build stops at linking, raise the limit (for example DefaultLimitNOFILE=524288 in /etc/systemd/system.conf, then log in again).")
    except (ImportError, ValueError, OSError) as e:
        log("open files limit: could not raise it (%s)" % e)


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))
            for b in ("linux", "linux_us"):
                rmtree(os.path.join(gdir, "build", b, "gen")); rmtree(os.path.join(gdir, "build", b, "obj"))
        unzip(zp, gdir, "build kit"); os.remove(zp)
        open(kv, "w").write(man["version"])   # (unpacked: an interrupted build resumes with what it compiled)

    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", "--region", g["region"], "--levels", levels, "--iso", iso, "--repo", "decomp", "-j", str(jobs)]
    log = open(os.path.join(gdir, "build_log.txt"), "w")
    raise_open_files()
    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"))
    bd = os.path.relpath(out_dir(g), gdir)
    for j in (bd + "/gen", bd + "/gen.new", bd + "/obj", bd + "/region", "build/levels", "build/levels_us", "build/disc", "zig-cache"): rmtree(os.path.join(gdir, j))
    c.set("disc", g["id"], iso); c.set("build", "built_" + g["id"], man["version"]); c.set("play", "game", g["id"]); save(c)
    other = [x for x in BY_ID.values() if x is not g and c.get("build", "built_" + x["id"], fallback="")]
    say("Built. Ready to play: project-solana play" + ("  (the other version: project-solana play --game %s)" % other[0]["region"] if other else ""))


# ---- play ----
def play(args):
    c = settings()
    g = chosen(c, args.game)
    iso = c.get("disc", g["id"], fallback="")
    exe = os.path.join(out_dir(g), g["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)
    pc["game"] = g["id"]
    save(c)
    env = dict(os.environ)
    deck = os.environ.get("SteamDeck") == "1" or is_steamos()   # (Steam Deck, Steam Machine: full screen by default)
    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")
    sdl_env(env)
    gdir = os.path.dirname(exe)
    out = os.path.join(gdir, "game_output.txt")   # what SDL, the graphics driver and the game print (kept: one run back)
    if os.path.exists(out): os.replace(out, out + ".prev")
    log("play: %s, %s (UYA_SCALE=%s, fullscreen %s, vsync %s)" % (g["title"], iso, env.get("UYA_SCALE", "1"), full, pc.get("vsync", "0")))
    with open(out, "w") as o:
        try:
            p = subprocess.Popen([exe, "-", iso], cwd=gdir, env=env, stdout=o, stderr=subprocess.STDOUT)
        except OSError as e:
            die("the game could not start (%s): run  project-solana status" % e)
        try: rc = p.wait()
        except KeyboardInterrupt: p.terminate(); rc = p.wait()
    import signal
    if rc == 0: log("play: the game closed normally"); return
    why = ("it crashed (%s)" % signal.Signals(-rc).name) if rc < 0 and -rc in [s.value for s in signal.Signals] else "it stopped with an error (code %d)" % rc
    die("the game closed: %s. project-solana report writes a file to send with a bug report" % why)


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 g in BY_ID.values():
        b = c.get("build", "built_" + g["id"], fallback="")
        say("%s: %s, disc %s" % (g["title"], ("built from kit " + b) if b else "not built", c.get("disc", g["id"], 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)
    c = settings()
    built = [g for g in BY_ID.values() if c.get("build", "built_" + g["id"], fallback="")] or [BY_ID["rac1"]]
    for g in built:
        name = "Ratchet & Clank (Project Solana)" if len(built) == 1 else "%s (Project Solana)" % g["title"]
        p = os.path.join(d, "project-solana-%s.desktop" % g["id"])
        with open(p, "w") as f:
            f.write("[Desktop Entry]\nType=Application\nName=%s\nComment=Built from your own disc\n"
                    "Exec=python3 \"%s\" play --game %s\nTerminal=false\nCategories=Game;\n" % (name, me, g["region"]))   # (python3: works without the executable bit)
        say("Added %s" % p)
        say("Steam: Games > Add a Non-Steam Game to My Library, and pick \"%s\"." % name)


def show_log(args):
    p = os.path.join(out_dir(chosen(settings(), args.game)), "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 tail(path, n):
    try:
        with open(path, errors="replace") as f: lines = f.readlines()
    except OSError:
        return "(none)\n"
    return ("(... %d earlier lines left out)\n" % (len(lines) - n) if len(lines) > n else "") + "".join(lines[-n:])


def report(args):
    """one text file with what a bug report needs; nothing from the game, and no file of the user's"""
    c = settings()
    g = chosen(c, args.game)
    gdir = os.path.join(HOME, "games", g["kit"])
    bdir = out_dir(g)
    out = []
    w = out.append
    w("Project Solana bug report (Linux launcher %s), %s\n" % (VERSION, time.strftime("%Y-%m-%d %H:%M:%S %z")))
    u = os.uname()
    pretty = "?"
    try:
        for l in open("/etc/os-release"):
            if l.startswith("PRETTY_NAME="): pretty = l.split("=", 1)[1].strip().strip('"')
    except OSError: pass
    w("\n== system\nLinux %s %s, %s\n" % (u.release, u.machine, pretty))
    try:
        cpu = next((l.split(":", 1)[1].strip() for l in open("/proc/cpuinfo") if l.startswith("model name")), "?")
        mem = next((l.split(":", 1)[1].strip() for l in open("/proc/meminfo") if l.startswith("MemTotal")), "?")
    except OSError: cpu = mem = "?"
    w("CPU %s, %d cores, RAM %s\n" % (cpu, os.cpu_count() or 0, mem))
    for k in ("XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "DISPLAY", "WAYLAND_DISPLAY", "SteamDeck", "SteamAppId"):
        w("%s=%s\n" % (k, os.environ.get(k, "")))
    for cmd in (["lspci"], ["glxinfo", "-B"]):
        if shutil.which(cmd[0]):
            try:
                r = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
                if cmd[0] == "lspci": r = "".join(l + "\n" for l in r.splitlines() if "VGA" in l or "3D" in l or "Display" in l)
                w("-- %s\n%s" % (" ".join(cmd), r))
            except Exception: pass
    w("\n== 32-bit libraries\n")
    for n in ("ld-linux.so.2", SDL, "libGL.so.1"):
        found = lib_candidates(n)
        w("%s: %s\n" % (n, ", ".join("%s (%s)" % (p, "32-bit" if elf32(p) else "not 32-bit") for p in found) or "none"))
    sp, steam = find_sdl()
    w("SDL2 the game uses: %s\n" % ((sp + (" (Steam's runtime)" if steam else "")) if sp else "MISSING"))
    w("\n== Project Solana\ndata folder: %s\n" % HOME)
    tv = os.path.join(HOME, "toolchain", "version.txt")
    w("build tools: %s\n" % (open(tv).read().strip() if os.path.exists(tv) else "not downloaded"))
    kv = os.path.join(gdir, "kit_version.txt")
    w("build kit: %s\n" % (open(kv).read().strip() if os.path.exists(kv) else "none"))
    w("game: %s\n" % g["title"])
    w("game built: %s\n" % ("yes" if os.path.exists(os.path.join(bdir, "rac1_wrapper")) else "no"))
    iso = c.get("disc", g["id"], fallback="")
    w("disc image: %s\n" % ((("found, %.2f GB" % (os.path.getsize(iso) / 1e9)) if os.path.exists(iso) else "MISSING (moved?)") if iso else "none"))
    w("settings:\n")
    for s in c.sections():
        for k, v in c.items(s):
            if s == "disc" or k == "release_url": continue
            w("  [%s] %s = %s\n" % (s, k, v))
    for title, path, n in (("launcher log", LAUNCHER_LOG, 60), ("build log (end)", os.path.join(gdir, "build_log.txt"), 80),
                           ("game log", os.path.join(bdir, "rac1_log.txt"), 400), ("game log, the run before", os.path.join(bdir, "rac1_log.prev.txt"), 150),
                           ("game output (SDL, graphics driver)", os.path.join(bdir, "game_output.txt"), 120),
                           ("game output, the run before", os.path.join(bdir, "game_output.txt.prev"), 60)):
        w("\n== %s\n%s" % (title, tail(path, n)))
    text = "".join(out).replace(os.path.expanduser("~"), "~")   # (no user name in the report)
    dest_dir = next((d for d in (os.path.expanduser("~/Desktop"), os.path.expanduser("~")) if os.path.isdir(d)), HOME)
    dest = os.path.join(dest_dir, "project-solana-report-%s.txt" % time.strftime("%Y%m%d-%H%M%S"))
    open(dest, "w").write(text)
    say("Wrote %s: attach it to your bug report (it has your system details and the logs, nothing else)." % dest)


def main():
    ap = argparse.ArgumentParser(prog="project-solana", description="Build Ratchet & Clank from your own disc, then play it.")
    ap.add_argument("--version", action="version", version="project-solana %s (Linux launcher)" % VERSION)
    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)")
    p.add_argument("--game", default=None, help="pal or us, when both are built (default: the last one built or played)")
    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")
    for n, h in (("log", "print the last game log"), ("report", "write one file with everything a bug report needs")):
        sp.add_parser(n, help=h).add_argument("--game", default=None, help="pal or us")
    a = ap.parse_args()
    if sys.platform != "linux": die("this is the Linux launcher: on Windows, use Project Solana.exe")
    log("project-solana %s: %s" % (VERSION, " ".join(sys.argv[1:])))
    try:   # downloads lose the executable bit: set it, so ./project-solana works next time
        me = os.path.abspath(sys.argv[0]); m = os.stat(me).st_mode
        if not m & 0o100: os.chmod(me, m | 0o111)
    except OSError:
        pass
    try:
        {"build": build, "play": play, "status": status, "desktop": desktop, "log": show_log, "report": report}.get(a.cmd, lambda _: ap.print_help())(a)
    except SystemExit:
        raise
    except KeyboardInterrupt:
        sys.exit(130)
    except Exception as e:   # (an unexpected error: in the launcher log too, with where)
        import traceback
        log("unexpected error:\n" + traceback.format_exc())
        die("unexpected error: %s (details in %s)" % (e, LAUNCHER_LOG))


if __name__ == "__main__":
    main()
