Added RS Games Linux client.

This commit is contained in:
Storm Dragon committed 2026-10-31 19:37:48 -04:00
1 parent 17c1f0f77d
commit fbafaed846
8 files changed
+271 -4

No files matched your search

@@ -1,6 +1,6 @@
# Linux Game Manager Core Map # Linux Game Manager Core Map
Last refreshed: 2026-10-30 Last refreshed: 2026-10-31
## Top-Level Structure ## Top-Level Structure
@@ -16,9 +16,9 @@ Last refreshed: 2026-10-30
## Catalog Snapshot ## Catalog Snapshot
- Installers: 49 (`.install/*.sh`) - Installers: 50 (`.install/*.sh`)
- Launcher definitions: 49 (`.launch/*.game`) - Launcher definitions: 50 (`.launch/*.game`)
- Launcher runnable entries: 11 (`.launch/*.sh`, both symlinks and files; varies with local installations) - Launcher runnable entries: 38 (`.launch/*.sh`, both symlinks and files; varies with local installations)
- Update scripts: 11 (`.update/*.sh`) - Update scripts: 11 (`.update/*.sh`)
Regenerate this snapshot with: 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. - 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. - 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. - `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. - 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>.game` exists and `.launch/<game>.sh` does not, the manager creates a symlink using the repository root, even if the installer changed directories. - If `.launch/<game>.game` exists and `.launch/<game>.sh` does not, the manager creates a symlink using the repository root, even if the installer changed directories.
@@ -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. - 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. - 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. - 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. - Avoid introducing unnecessary colorized output.
- For edited bash scripts, run shellcheck and fix all errors. - 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`). - If `shellcheck` is not installed, prompt the user to install it first (see `references/tooling-prereqs.md`).
+84
View File
@@ -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
+113
View File
@@ -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
+29
View File
@@ -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
+14
View File
@@ -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
+6
View File
@@ -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
+19
View File
@@ -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 need to be tracked manually. Updating preserves the portable `saves` directory
under `~/.local/games/Wickets`. Wickets 1.2.0 also includes its own updater. 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 ## Disabling game entries
To disable a game entry without deleting its script, set the first line of the script to start with `#//`. To disable a game entry without deleting its script, set the first line of the script to start with `#//`.