diff --git a/.gitignore b/.gitignore index dc4b846..ac235d6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ games/*/zig-cache/ games/*/build_log.txt __pycache__/ *.pyc -release/rac1/ \ No newline at end of file +release/*/ diff --git a/README.md b/README.md index d8be259..9a2a2b6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The game runs natively on Linux (no Wine or Proton). The Linux launcher is a sin ./project-solana desktop # adds it to the applications menu; from there, Steam > Add a Non-Steam Game ``` -It needs `python3` and the **32-bit** SDL2 and OpenGL libraries. Steam installs them, so SteamOS and any PC with Steam already has them; otherwise `./project-solana status` says what to install for your distribution. Everything is kept in `~/.local/share/project-solana`. On a Steam Deck, run the build once in Desktop Mode (Konsole), then add the game to Steam with `desktop`; it starts full screen there, with the Deck's controls as the controller. +It needs `python3` and the **32-bit** SDL2 and OpenGL libraries. On SteamOS (Steam Deck, Steam Machine) nothing needs installing: the launcher uses the copy of SDL2 that comes with Steam. Elsewhere, `./project-solana status` says what to install for your distribution. Everything is kept in `~/.local/share/project-solana`. On a Steam Deck, run the build once in Desktop Mode (Konsole), then add the game to Steam with `desktop`; it starts full screen there (and on any SteamOS device), with the Deck's controls as the controller. ### Supported games @@ -88,4 +88,8 @@ gh release create rac1 dist/rac1/rac1.json dist/rac1/rac1-build.zip dist/rac1/to To update an existing game release, upload the new files over the old ones (`gh release upload rac1 dist/rac1/* --clobber`). Players get them with **Rebuild the game**. -The launchers go in a release of their own (for example `launcher-v1`): `Project Solana.exe` and `project-solana` (Linux). \ No newline at end of file +The launchers go in a release of their own (for example `launcher-v1`): `Project Solana.exe` and `project-solana` (Linux). + +## Licence + +TODO (maintainers): choose a licence for this repository. Third-party code keeps its own (see `THIRD_PARTY_NOTICES.md`). diff --git a/launcher/linux/project-solana b/launcher/linux/project-solana index 51a083e..d852fb7 100755 --- a/launcher/linux/project-solana +++ b/launcher/linux/project-solana @@ -26,7 +26,7 @@ OTHER = {"SCUS_971.99": "Ratchet & Clank (NTSC-U)", "SCPS_150.37": "Ratchet & Cl "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" +VERSION = "1.4" 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") @@ -161,36 +161,115 @@ def rmtree(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"] +# 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 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()) +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("", 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 = [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)") + 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)) - say("Installing Steam brings them. Otherwise:") + 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: sudo dnf install glibc.i686 SDL2.i686 mesa-libGL.i686 mesa-dri-drivers.i686") + 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) @@ -304,7 +383,7 @@ def play(args): if v is not None: pc[k] = str(v) save(c) env = dict(os.environ) - deck = os.environ.get("SteamDeck") == "1" + 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 @@ -316,6 +395,7 @@ def play(args): 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") @@ -401,8 +481,11 @@ def report(args): 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"))) + 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"))