#!/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)", "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)"}

VERSION = "1.2"
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 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 lib_paths(name):
    return [os.path.join(d, name) for d in LIBDIRS + ["/lib", "/usr/lib"] if os.path.exists(os.path.join(d, name))]


def check_libs(quiet=False):
    missing = [n for n in ("libSDL2-2.0.so.0", "libGL.so.1") if not have_lib(n)]
    if not lib_paths("ld-linux.so.2"): missing.insert(0, "the 32-bit C library (ld-linux.so.2)")
    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 libc6:i386 libsdl2-2.0-0:i386 libgl1:i386 libgl1-mesa-dri:i386")
        say("  Fedora:                          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


# ---- 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))
            rmtree(os.path.join(gdir, "build", "linux", "gen")); rmtree(os.path.join(gdir, "build", "linux", "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", "--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"))
    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))
    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")
    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 (UYA_SCALE=%s, fullscreen %s, vsync %s)" % (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 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 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()
    gdir = os.path.join(HOME, "games", "rac1")
    bdir = os.path.join(gdir, "build", "linux")
    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", "libSDL2-2.0.so.0", "libGL.so.1"):
        w("%s: %s\n" % (n, ", ".join(lib_paths(n)) or ("found by ldconfig" if have_lib(n) 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 built: %s\n" % ("yes" if os.path.exists(os.path.join(bdir, "rac1_wrapper")) else "no"))
    iso = c.get("disc", "rac1", 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)")
    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")
    sp.add_parser("report", help="write one file with everything a bug report needs")
    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:
        {"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()
