diff --git a/.codex/skills/linux-game-manager-dev/references/core-map.md b/.codex/skills/linux-game-manager-dev/references/core-map.md index 388efe1..2fb834f 100644 --- a/.codex/skills/linux-game-manager-dev/references/core-map.md +++ b/.codex/skills/linux-game-manager-dev/references/core-map.md @@ -1,6 +1,6 @@ # Linux Game Manager Core Map -Last refreshed: 2026-10-30 +Last refreshed: 2026-10-31 ## Top-Level Structure @@ -16,9 +16,9 @@ Last refreshed: 2026-10-30 ## Catalog Snapshot -- Installers: 49 (`.install/*.sh`) -- Launcher definitions: 49 (`.launch/*.game`) -- Launcher runnable entries: 11 (`.launch/*.sh`, both symlinks and files; varies with local installations) +- Installers: 50 (`.install/*.sh`) +- Launcher definitions: 50 (`.launch/*.game`) +- Launcher runnable entries: 38 (`.launch/*.sh`, both symlinks and files; varies with local installations) - Update scripts: 11 (`.update/*.sh`) Regenerate this snapshot with: @@ -35,6 +35,7 @@ python3 .codex/skills/linux-game-manager-dev/scripts/audit_game_catalog.py - Monkey Spank clones its required submodules recursively and stops installation if cloning fails. - Shared downloads try `curl` first, fall back to `wget` when available, validate archive payloads before install, and reject obvious HTML error pages for unknown file types. - `download_named()` shares the same cache validation path as `download()`, so resolved asset URLs must still produce a valid payload for their file extension. +- RS Games installs the fixed 2.3 x86_64 archive under `${installPath}/RS_Games` and launches `./rsg` from that directory. The archive omits `speechd`; the installer requires host Python Speech Dispatcher bindings and copies their Python sources into `_internal/speechd`. Run `.files/rs-games-alsa.py` to silently test default-device initialization with the bundled library. Keep a working bundle untouched. Only set it aside as `libasound.so.2.bundled` when bundled initialization fails and system ALSA passes the same check; restore it if the system check fails. Inconclusive initial probes retain the bundle and report diagnostics. The bundled library has incompatible plugin paths and symbol versions on Arch Linux. Run `python3 -B -m unittest discover -s tests` for ALSA selection/restoration and path-resolution regressions. Run `.files/rs-games-audio.py` after extraction to correct non-Windows file-path encoding in `sound_lib.stream`; the helper renames its frozen PYZ entry so the corrected filesystem source is used and preserves `rsg.lgm-original`. Confirm with the frozen importer, since direct BASS decoding bypasses the failing wrapper. The repair also installs `sound_lib/lgm_paths.py` for per-call case-insensitive sound lookup: exact paths win, otherwise each component must have one case-insensitive match; missing or ambiguous paths stay unchanged. Downloads are never renamed and misses are not cached. Validate with `python3 -B -m unittest discover -s tests -p "test_rs_games_paths.py"` and the actual frozen importer. Client logs are in `~/.ZGP/errors.log`. - Wickets resolves the latest stable GitHub release on each install/update, extracts its Linux x64 tarball into staging, and overlays `${installPath}/Wickets` while preserving `saves`. A `.lgm-version` marker records the release installed by the manager. - If `.launch/.game` exists and `.launch/.sh` does not, the manager creates a symlink using the repository root, even if the installer changed directories. diff --git a/.codex/skills/linux-game-manager-dev/references/game-extension.md b/.codex/skills/linux-game-manager-dev/references/game-extension.md index b550981..1ff86ad 100644 --- a/.codex/skills/linux-game-manager-dev/references/game-extension.md +++ b/.codex/skills/linux-game-manager-dev/references/game-extension.md @@ -107,6 +107,7 @@ python3 .codex/skills/linux-game-manager-dev/scripts/audit_game_catalog.py - Clone games with required submodules using `git clone --recurse-submodules` and stop installation on clone failure. - Launch games that load relative assets from their own directory. Monkey Spank uses `${installPath}/monkeyspank` as its working directory and removal root. - Wickets shares install/update logic in `.includes/wickets.sh`; its versioned tarball has one top-level directory, and portable saves must survive updates. Always refresh GitHub release metadata rather than reusing a cached latest-release response. +- RS Games 2.3 has a single `rsg/` archive root; strip that component into `${installPath}/RS_Games` and launch `./rsg` from that directory. Require `python-speechd:speechd` and copy its Python sources into `_internal/speechd`, since the frozen runtime cannot import host site-packages and the release omits these bindings. Run `.files/rs-games-alsa.py` to silently test default-device initialization with the bundled library. Keep a working bundle untouched. Only set it aside as `libasound.so.2.bundled` when bundled initialization fails and system ALSA passes the same check; restore it if the system check fails. Inconclusive initial probes retain the bundle and report diagnostics. The bundled library has incompatible plugin paths and symbol versions on Arch Linux. Run `python3 -B -m unittest discover -s tests` for ALSA selection/restoration and path-resolution regressions. Run `.files/rs-games-audio.py` after extraction to correct non-Windows file-path encoding in `sound_lib.stream`; the helper renames its frozen PYZ entry so the corrected filesystem source is used and preserves `rsg.lgm-original`. Confirm with the frozen importer, since direct BASS decoding bypasses the failing wrapper. The repair also installs `sound_lib/lgm_paths.py` for per-call case-insensitive sound lookup: exact paths win, otherwise each component must have one case-insensitive match; missing or ambiguous paths stay unchanged. Downloads are never renamed and misses are not cached. Validate with `python3 -B -m unittest discover -s tests -p "test_rs_games_paths.py"` and the actual frozen importer. Client logs are in `~/.ZGP/errors.log`. - Avoid introducing unnecessary colorized output. - For edited bash scripts, run shellcheck and fix all errors. - If `shellcheck` is not installed, prompt the user to install it first (see `references/tooling-prereqs.md`). diff --git a/.files/rs-games-alsa.py b/.files/rs-games-alsa.py new file mode 100644 index 0000000..1703bea --- /dev/null +++ b/.files/rs-games-alsa.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Keep bundled ALSA unless the system copy demonstrably fixes audio startup.""" + +import ctypes +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +def probe_audio(gamePath): + probeEnvironment = os.environ.copy() + libraryPath = str(gamePath / "_internal") + if probeEnvironment.get("LD_LIBRARY_PATH"): + libraryPath += os.pathsep + probeEnvironment["LD_LIBRARY_PATH"] + probeEnvironment["LD_LIBRARY_PATH"] = libraryPath + try: + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--probe", str(gamePath)], + env=probeEnvironment, capture_output=True, text=True, timeout=10, check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return 2, str(error) + return result.returncode, (result.stdout + result.stderr).strip() + + +def configure_alsa(gamePath): + libraryPath = gamePath / "_internal/libasound.so.2" + if not libraryPath.exists(): + print("RS Games has no active bundled ALSA library; leaving it unchanged.") + return + probeStatus, probeDetails = probe_audio(gamePath) + if probeStatus == 0: + print("RS Games bundled ALSA initializes successfully; keeping it.") + return + if probeStatus != 1: + print(f"Could not verify RS Games audio; keeping bundled ALSA.\n{probeDetails}") + return + + backupPath = libraryPath.with_name("libasound.so.2.bundled") + if backupPath.exists() and backupPath.read_bytes() != libraryPath.read_bytes(): + raise ValueError("Existing ALSA backup differs; refusing to overwrite") + libraryPath.replace(backupPath) + systemWorks = False + try: + systemStatus, systemDetails = probe_audio(gamePath) + systemWorks = systemStatus == 0 + finally: + if not systemWorks: + shutil.copy2(backupPath, libraryPath) + if systemWorks: + print("RS Games bundled ALSA failed; verified system ALSA and preserved the bundled copy.") + else: + print("Neither ALSA setup could be verified; restored bundled ALSA.") + print(f"Bundled: {probeDetails}\nSystem: {systemDetails}") + + +def run_probe(gamePath): + try: + bassLibrary = ctypes.CDLL(str(gamePath / "_internal/sound_lib/lib/x64/libbass.so")) + bassLibrary.BASS_Init.argtypes = [ + ctypes.c_int, ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, + ] + if not bassLibrary.BASS_Init(1, 44100, 0, None, None): + errorCode = bassLibrary.BASS_ErrorGetCode() + print(f"Default audio device initialization failed (BASS error {errorCode}).") + return 1 if errorCode == 3 else 2 + bassLibrary.BASS_Free() + return 0 + except OSError as error: + print(f"Audio library could not load: {error}") + return 2 + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--probe": + raise SystemExit(run_probe(Path(sys.argv[2]).resolve())) + if len(sys.argv) != 2: + raise SystemExit("Usage: rs-games-alsa.py GAME_DIRECTORY") + try: + configure_alsa(Path(sys.argv[1]).resolve()) + except (OSError, ValueError) as error: + raise SystemExit(f"RS Games ALSA setup failed: {error}") from error diff --git a/.files/rs-games-audio.py b/.files/rs-games-audio.py new file mode 100644 index 0000000..631f7e7 --- /dev/null +++ b/.files/rs-games-audio.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Repair Linux file paths in the frozen RS Games 2.3 sound wrapper.""" + +import marshal +from pathlib import Path +import shutil +import struct +import sys + + +def repair_audio(gamePath): + executablePath = gamePath / "rsg" + streamPath = gamePath / "_internal/sound_lib/stream.py" + executableData = executablePath.read_bytes() + streamData = streamPath.read_bytes() + originalBlock = ( + b'if platform.system() == "Darwin" and file and not mem:\r\n' + b' unicode = False\r\n' + b' file = file.encode(sys.getfilesystemencoding())' + ) + encodedBlock = ( + b'if platform.system() != "Windows" and not mem and isinstance(file, (str, bytes, os.PathLike)):\r\n' + b' unicode = False\r\n' + b' file = os.fsencode(file)' + ) + repairedBlock = ( + b'if platform.system() != "Windows" and not mem and isinstance(file, (str, bytes, os.PathLike)):\r\n' + b' from .lgm_paths import resolve_sound_path\r\n' + b' unicode = False\r\n' + b' file = os.fsencode(resolve_sound_path(file))' + ) + resolverData = Path(__file__).with_name("rs-games-paths.py").read_bytes() + if streamData.count(originalBlock) == 1: + repairedStream = streamData.replace(originalBlock, repairedBlock) + elif streamData.count(encodedBlock) == 1: + repairedStream = streamData.replace(encodedBlock, repairedBlock) + elif streamData.count(repairedBlock) == 1: + repairedStream = streamData + else: + raise ValueError("Unexpected RS Games sound wrapper; refusing to patch") + + # Rename only this module's PYZ entry. PyInstaller then falls back to the + # supplied filesystem source, making the corrected stream.py effective. + cookiePosition = executableData.rfind(b"MEI\014\013\012\013\016") + if cookiePosition < 0: + raise ValueError("PyInstaller archive cookie not found") + _, packageSize, tocOffset, tocSize, pythonVersion, _ = struct.unpack_from( + "!8sIIII64s", executableData, cookiePosition + ) + if pythonVersion != 312: + raise ValueError("Expected the Python 3.12 RS Games release") + packageStart = cookiePosition + 88 - packageSize + entryPosition = packageStart + tocOffset + tocEnd = entryPosition + tocSize + pyzStart = None + pyzEnd = None + while entryPosition < tocEnd: + entrySize, offset, dataSize, _, compressed, entryType = struct.unpack_from( + "!IIIIBc", executableData, entryPosition + ) + if entrySize < 18 or entryPosition + entrySize > tocEnd: + raise ValueError("Invalid archive table entry") + entryName = executableData[entryPosition + 18:entryPosition + entrySize].rstrip(b"\0") + if entryName == b"PYZ.pyz" and entryType == b"z" and not compressed: + pyzStart = packageStart + offset + pyzEnd = pyzStart + dataSize + entryPosition += entrySize + if pyzStart is None or executableData[pyzStart:pyzStart + 4] != b"PYZ\0": + raise ValueError("Uncompressed PYZ archive not found") + pyzTocStart = pyzStart + struct.unpack_from("!i", executableData, pyzStart + 8)[0] + if not pyzStart + 12 <= pyzTocStart < pyzEnd <= cookiePosition: + raise ValueError("Invalid PYZ table bounds") + tableData = executableData[pyzTocStart:pyzEnd] + originalName = b"sound_lib.stream" + savedName = b"lgm_saved_stream" + originalTable = dict(marshal.loads(tableData)) + if "sound_lib.stream" in originalTable and "lgm_saved_stream" not in originalTable: + if tableData.count(originalName) != 1: + raise ValueError("Ambiguous sound module table entry") + repairedTableData = tableData.replace(originalName, savedName) + expectedTable = originalTable.copy() + expectedTable["lgm_saved_stream"] = expectedTable.pop("sound_lib.stream") + if dict(marshal.loads(repairedTableData)) != expectedTable: + raise ValueError("Archive table verification failed") + repairedExecutable = executableData[:pyzTocStart] + repairedTableData + executableData[pyzEnd:] + elif "lgm_saved_stream" in originalTable and "sound_lib.stream" not in originalTable: + repairedExecutable = executableData + else: + raise ValueError("Unexpected sound module archive entries") + + if repairedExecutable != executableData: + backupPath = executablePath.with_name("rsg.lgm-original") + if backupPath.exists() and backupPath.read_bytes() != executableData: + raise ValueError("Existing executable backup differs; refusing to overwrite") + if not backupPath.exists(): + shutil.copy2(executablePath, backupPath) + streamPath.with_name("lgm_paths.py").write_bytes(resolverData) + streamPath.write_bytes(repairedStream) + if repairedExecutable != executableData: + temporaryPath = executablePath.with_name("rsg.lgm-new") + temporaryPath.write_bytes(repairedExecutable) + temporaryPath.chmod(executablePath.stat().st_mode) + temporaryPath.replace(executablePath) + print("RS Games Linux audio file paths repaired.") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: rs-games-audio.py GAME_DIRECTORY") + try: + repair_audio(Path(sys.argv[1])) + except (OSError, ValueError, EOFError, TypeError, struct.error) as error: + raise SystemExit(f"RS Games audio repair failed: {error}") from error diff --git a/.files/rs-games-paths.py b/.files/rs-games-paths.py new file mode 100644 index 0000000..750961a --- /dev/null +++ b/.files/rs-games-paths.py @@ -0,0 +1,29 @@ +"""Resolve uniquely matching sound paths from Windows-authored game packs.""" + +import os + + +def resolve_sound_path(filePath): + originalPath = os.fspath(filePath) + if os.path.exists(originalPath): + return originalPath + + pathText = os.fsdecode(originalPath) + resolvedPath = os.path.sep if os.path.isabs(pathText) else os.curdir + for component in pathText.split(os.path.sep): + if component in ("", "."): + continue + candidatePath = os.path.join(resolvedPath, component) + if os.path.exists(candidatePath): + resolvedPath = candidatePath + continue + try: + with os.scandir(resolvedPath) as entries: + matches = [entry.name for entry in entries if entry.name.casefold() == component.casefold()] + except OSError: + return originalPath + if len(matches) != 1: + return originalPath + resolvedPath = os.path.join(resolvedPath, matches[0]) + + return os.fsencode(resolvedPath) if isinstance(originalPath, bytes) else resolvedPath diff --git a/.install/RS Games.sh b/.install/RS Games.sh new file mode 100644 index 0000000..6063d72 --- /dev/null +++ b/.install/RS Games.sh @@ -0,0 +1,14 @@ +# shellcheck shell=bash disable=SC2154 # installPath and cache are supplied by the manager. +check_architecture x86_64 +check_dependencies python-speechd:speechd +download "https://github.com/rsgames-zgp/rsgames-client-releases/releases/download/v2.3/rsg-linux-x86_64.tar.gz" +mkdir -p "${installPath}/RS_Games" || exit 1 +tar -xzf "${cache}/rsg-linux-x86_64.tar.gz" --strip-components=1 -C "${installPath}/RS_Games" || exit 1 +# Keep bundled ALSA when it works; use system ALSA only after verifying it. +python3 "${scriptRoot}/.files/rs-games-alsa.py" "${installPath}/RS_Games" || exit 1 +chmod +x "${installPath}/RS_Games/rsg" || exit 1 +# The frozen client omits speechd and cannot import host site-packages. +speechdPath="$(python3 -c 'import pathlib, speechd; print(pathlib.Path(speechd.__file__).resolve().parent)')" || exit 1 +mkdir -p "${installPath}/RS_Games/_internal/speechd" || exit 1 +cp "${speechdPath}/"*.py "${installPath}/RS_Games/_internal/speechd/" || exit 1 +python3 "${scriptRoot}/.files/rs-games-audio.py" "${installPath}/RS_Games" || exit 1 diff --git a/.launch/RS Games.game b/.launch/RS Games.game new file mode 100644 index 0000000..6bd74f4 --- /dev/null +++ b/.launch/RS Games.game @@ -0,0 +1,6 @@ +# shellcheck shell=bash disable=SC2154 # installPath and fex are supplied by the manager. +check_architecture x86_64 +gamePath="${installPath}/RS_Games" +cd "${gamePath}" || exit 1 +# shellcheck disable=SC2086 # fex intentionally expands to an optional command prefix. +run_with_optional_gamemode ${fex}./rsg diff --git a/README.md b/README.md index f8fdd49..6cf9bac 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ query GitHub for the latest stable release, so versioned download links do not need to be tracked manually. Updating preserves the portable `saves` directory under `~/.local/games/Wickets`. Wickets 1.2.0 also includes its own updater. +## RS Games + +Install RS Games 2.3 on x86_64 Linux by selecting RS Games under +`./linux-game-manager.sh -i`. The client is installed under +`~/.local/games/RS_Games` and can then be launched from the main game menu. +The installer requires the host Python Speech Dispatcher bindings (`python-speechd` +on Arch Linux or `python3-speechd` on Debian) and copies them into the client, +whose Linux release omits them. +The installer silently checks audio initialization and keeps bundled ALSA when +it works. If it fails and system ALSA passes the same check, the bundled copy +is set aside as `_internal/libasound.so.2.bundled`. If audio cannot be verified, +the bundled library is retained and the installer reports the problem. +The installer also repairs Linux audio file-path handling in the sound wrapper +and preserves the original executable as `rsg.lgm-original`. +Sound lookup automatically handles capitalization differences in filenames and +directories. Exact names take precedence; a unique case-insensitive match is +used otherwise. Downloaded files retain their original names. +Client errors are logged in `~/.ZGP/errors.log`. + ## Disabling game entries To disable a game entry without deleting its script, set the first line of the script to start with `#//`.