Release 1.1.3 with game window and Gamescope lifecycle fixes

This commit is contained in:
2026-10-25 20:47:19 +01:00
parent b6de80e758
commit a50d006e09
42 changed files with 9291 additions and 153 deletions

5
.gitignore vendored
View File

@@ -31,7 +31,10 @@ server/telemetry/.env
/infra/
/artifacts/
/release_tests/
/tests/
/tests/*
!/tests/test_game_launch_compatibility.py
!/tests/test_launcher_resilience.py
!/tests/test_window_focus_regressions.py
/tools/
/.gitea/
/.run/

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux
Version 1.1.1 installs, updates, repairs, and launches PristonTale EU through a
Version 1.1.3 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not
included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v1-1-1.tar.gz.sig \
pristontale-eu-linux-installer-v1-1-1.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-1-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-1-1.tar.gz
cd pristontale-eu-linux-installer-v1-1-1
-signature pristontale-eu-linux-installer-v1-1-3.tar.gz.sig \
pristontale-eu-linux-installer-v1-1-3.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-1-3.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-1-3.tar.gz
cd pristontale-eu-linux-installer-v1-1-3
./install.sh
```

View File

@@ -0,0 +1,53 @@
# Gamescope compatibility build provenance
This directory is the canonical source record for the patched Gamescope helper
shipped by the launcher. The executable remains in the Python package because
runtime lookup and installed paths are compatibility interfaces.
## Artifact
| Property | Recorded value |
| --- | --- |
| Source package | Ubuntu `gamescope 3.16.20+ds-1` |
| Build environment | `ubuntu:26.04` container |
| Target | Ubuntu 26.04, amd64, Linux x86-64 baseline |
| Packaged source | `src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64` |
| Installed path | `ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64` |
| ELF build ID | `165d21610879119d75ab8e4c3d7389d8f0743926` |
| SHA-256 | `3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639` |
`source.sha256` records the exact Ubuntu source archives. Obtain both archives
without renaming them and place them in
`.artifacts/native/gamescope/sources/`:
- `gamescope_3.16.20+ds.orig.tar.xz`
- `gamescope_3.16.20+ds-1.debian.tar.xz`
The patches are applied in this order:
1. `gamescope-wayland-letterbox-backing.patch`
2. `gamescope-wayland-libdecor-input-scope.patch`
3. `gamescope-lazy-output-images.patch`
4. `gamescope-wayland-live-resize.patch`
5. `gamescope-keep-nested-windows-visible.patch`
## Build
Run the recipe from any directory:
```text
./native/gamescope/build.sh
```
Pass a different source-archive directory as the first argument and an output
directory as the second argument when needed. Set `CONTAINER_ENGINE` to select
Docker or Podman explicitly. The recipe builds with `dpkg-buildpackage` in an
Ubuntu 26.04 amd64 container, writes below `.artifacts/` by default, and checks
the result against `binary.sha256`. It refuses to write over the shipped helper.
The container downloads build dependencies, so the Ubuntu 26.04 package
repositories used at build time remain part of the build environment.
The files named `gamescope-compat.README`, `gamescope-compat.copyright`, and the
five patches continue to be staged at their historical flat installed paths.
The full Debian copyright record is retained in
`gamescope-compat.copyright`.

View File

@@ -0,0 +1 @@
3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639 gamescope-3.16.20-ubuntu26.04-amd64

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]
then
sed -i 's/^Types: deb$/Types: deb deb-src/' \
/etc/apt/sources.list.d/ubuntu.sources
fi
apt-get update
apt-get install --yes build-essential ca-certificates devscripts dpkg-dev patch xz-utils
apt-get build-dep --yes gamescope
BUILD_ROOT="$(mktemp -d /tmp/ptlinux-gamescope.XXXXXX)"
trap 'rm -rf -- "$BUILD_ROOT"' EXIT
tar --extract \
--file /sources/gamescope_3.16.20+ds.orig.tar.xz \
--directory "$BUILD_ROOT"
SOURCE_ROOT="$(find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
if [[ -z "$SOURCE_ROOT" ]]
then
printf '%s\n' "The Gamescope source archive has no top-level directory." >&2
exit 1
fi
tar --extract \
--file /sources/gamescope_3.16.20+ds-1.debian.tar.xz \
--directory "$SOURCE_ROOT"
PATCH_ROOT="/workspace/native/gamescope/patches"
PATCH_SERIES=(
gamescope-wayland-letterbox-backing.patch
gamescope-wayland-libdecor-input-scope.patch
gamescope-lazy-output-images.patch
gamescope-wayland-live-resize.patch
gamescope-keep-nested-windows-visible.patch
)
mkdir -p -- "${SOURCE_ROOT}/debian/patches"
touch "${SOURCE_ROOT}/debian/patches/series"
for patch_name in "${PATCH_SERIES[@]}"
do
install -m 0644 \
"${PATCH_ROOT}/${patch_name}" \
"${SOURCE_ROOT}/debian/patches/${patch_name}"
if ! grep -Fqx -- "$patch_name" "${SOURCE_ROOT}/debian/patches/series"
then
printf '%s\n' "$patch_name" >> "${SOURCE_ROOT}/debian/patches/series"
fi
done
(
cd "$SOURCE_ROOT"
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage --build=binary --no-sign -jauto
)
PACKAGE="$(find "$BUILD_ROOT" -maxdepth 1 -type f \
-name 'gamescope_*_amd64.deb' -print -quit)"
if [[ -z "$PACKAGE" ]]
then
printf '%s\n' "The Gamescope build did not produce an amd64 Debian package." >&2
exit 1
fi
EXTRACT_ROOT="${BUILD_ROOT}/package"
dpkg-deb --extract "$PACKAGE" "$EXTRACT_ROOT"
if [[ -x "${EXTRACT_ROOT}/usr/games/gamescope" ]]
then
BINARY="${EXTRACT_ROOT}/usr/games/gamescope"
elif [[ -x "${EXTRACT_ROOT}/usr/bin/gamescope" ]]
then
BINARY="${EXTRACT_ROOT}/usr/bin/gamescope"
else
printf '%s\n' "The Gamescope Debian package contains no executable." >&2
exit 1
fi
install -m 0755 "$BINARY" \
/output/gamescope-3.16.20-ubuntu26.04-amd64
chown "${HOST_UID}:${HOST_GID}" \
/output/gamescope-3.16.20-ubuntu26.04-amd64

63
native/gamescope/build.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
PROVENANCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${PROVENANCE_ROOT}/../.." && pwd)"
SOURCE_ARCHIVE_DIRECTORY="${1:-${PROJECT_ROOT}/.artifacts/native/gamescope/sources}"
OUTPUT_DIRECTORY="${2:-${PROJECT_ROOT}/.artifacts/native/gamescope}"
ARTIFACT_NAME="gamescope-3.16.20-ubuntu26.04-amd64"
SHIPPED_ARTIFACT="${PROJECT_ROOT}/src/ptlinux/native/${ARTIFACT_NAME}"
if [[ ! -d "$SOURCE_ARCHIVE_DIRECTORY" ]]
then
printf 'Gamescope source-archive directory does not exist: %s\n' \
"$SOURCE_ARCHIVE_DIRECTORY" >&2
exit 2
fi
mkdir -p -- "$OUTPUT_DIRECTORY"
SOURCE_ARCHIVE_DIRECTORY="$(cd -- "$SOURCE_ARCHIVE_DIRECTORY" && pwd)"
OUTPUT_DIRECTORY="$(cd -- "$OUTPUT_DIRECTORY" && pwd)"
if [[ "${OUTPUT_DIRECTORY}/${ARTIFACT_NAME}" == "$SHIPPED_ARTIFACT" ]]
then
printf '%s\n' "Refusing to overwrite the shipped Gamescope executable." >&2
exit 2
fi
(
cd "$SOURCE_ARCHIVE_DIRECTORY"
sha256sum --check "${PROVENANCE_ROOT}/source.sha256"
)
if [[ -n "${CONTAINER_ENGINE:-}" ]]
then
ENGINE="$CONTAINER_ENGINE"
elif command -v podman >/dev/null 2>&1
then
ENGINE="podman"
elif command -v docker >/dev/null 2>&1
then
ENGINE="docker"
else
printf '%s\n' "Docker or Podman is required to rebuild Gamescope." >&2
exit 1
fi
if ! command -v "$ENGINE" >/dev/null 2>&1
then
printf 'Container engine was not found: %s\n' "$ENGINE" >&2
exit 1
fi
rm -f -- "${OUTPUT_DIRECTORY}/${ARTIFACT_NAME}"
"$ENGINE" run --rm --platform linux/amd64 \
--env "HOST_UID=$(id -u)" \
--env "HOST_GID=$(id -g)" \
--volume "${PROJECT_ROOT}:/workspace:ro" \
--volume "${SOURCE_ARCHIVE_DIRECTORY}:/sources:ro" \
--volume "${OUTPUT_DIRECTORY}:/output" \
ubuntu:26.04 \
bash /workspace/native/gamescope/build-in-container.sh
(
cd "$OUTPUT_DIRECTORY"
sha256sum --check "${PROVENANCE_ROOT}/binary.sha256"
)

View File

@@ -0,0 +1,2 @@
a27984fdab2bbfe353efc772de214ae88cccab07c4507747ed4b7f063b2faa0e gamescope_3.16.20+ds.orig.tar.xz
ae6e9bac39b576e7d9c15912a713796049a29756f6858364ef49bd02197cb153 gamescope_3.16.20+ds-1.debian.tar.xz

View File

@@ -234,7 +234,7 @@ ptlinux_detect_missing_dependencies()
if ! command -v "$python_bin" >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(python python-tk)
elif ! "$python_bin" -c 'import _tkinter' >/dev/null 2>&1
elif ! "$python_bin" -c 'import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()' >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(python-tk)
fi

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "PTEU-Linux-Launcher"
version = "1.1.1"
version = "1.1.3"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11"
dependencies = []

View File

@@ -3,11 +3,12 @@
Manual publication mode is currently active. Build a complete local package with:
```text
./scripts/release.sh stable 1.8.0
./scripts/release.sh prerelease 1.9.0-beta.1
SIGNING_KEY_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/pristontale-eu/release/launcher-update-private.pem"
PTLINUX_SIGNING_KEY="$SIGNING_KEY_PATH" ./scripts/release.sh stable 1.8.0
PTLINUX_SIGNING_KEY="$SIGNING_KEY_PATH" ./scripts/release.sh prerelease 1.9.0-beta.1
```
The command creates `dist/v<VERSION>/`, validates the channel/version pair,
The command creates `.artifacts/releases/v<VERSION>/`, validates the channel/version pair,
builds and previews release notes, runs the configured preflight, creates a
deterministic archive, signs the manifest, and verifies the complete result. It
changes the requested version only inside the staged package, so the source
@@ -24,11 +25,15 @@ releases after `0.9.11` return to the flat layout.
Use `--description` for short Markdown or `--description-file` for a prepared
Markdown document. Generated commit notes are appended by default. Run
`./scripts/release.sh --help` for dry-run, editor, changelog-only, signing-key,
and test options.
output-directory, and test options. Custom release output must remain below
`.artifacts/releases/` or an operating-system temporary directory. See the
[artifact layout and migration guide](../docs/development/ARTIFACTS.md) for all
generated-output locations. The private signing key has no checkout default;
provide a mode-`0600` regular file outside the repository and artifact output.
After a successful build, create a new release in Gitea with tag `v<VERSION>`.
Leave **Pre-release** clear for `stable`; select it for `prerelease`. Use
`dist/v<VERSION>/release-notes.md` as the description and upload exactly these
`.artifacts/releases/v<VERSION>/release-notes.md` as the description and upload exactly these
five files from that version directory:
- `PristonTale-EU-<VERSION>.tar.gz`
@@ -43,14 +48,14 @@ signed archive URL includes its SHA-256 as a query parameter so Cloudflare and
other CDNs cannot reuse an empty response cached before the attachment existed.
After the Gitea release exists and each attachment has a nonzero size, publish
the matching files generated under
`dist/v<VERSION>/channel/` to the repository:
`.artifacts/releases/v<VERSION>/channel/` to the repository:
```text
dist/v<VERSION>/channel/stable.json -> updates/stable.json
dist/v<VERSION>/channel/stable.json.sig -> updates/stable.json.sig
.artifacts/releases/v<VERSION>/channel/stable.json -> updates/stable.json
.artifacts/releases/v<VERSION>/channel/stable.json.sig -> updates/stable.json.sig
dist/v<VERSION>/channel/prerelease.json -> updates/prerelease.json
dist/v<VERSION>/channel/prerelease.json.sig -> updates/prerelease.json.sig
.artifacts/releases/v<VERSION>/channel/prerelease.json -> updates/prerelease.json
.artifacts/releases/v<VERSION>/channel/prerelease.json.sig -> updates/prerelease.json.sig
```
Commit and push only the selected channel pair. Publishing a prerelease must not
@@ -71,8 +76,8 @@ For direct artifact tooling without release notes or manual-upload guidance:
```text
PTLINUX_PYTHON=/path/to/python \
./scripts/build-release.sh 0.9.11 stable ./dist/v0.9.11
./scripts/verify-release.sh 0.9.11 stable ./dist/v0.9.11
./scripts/build-release.sh 0.9.11 stable ./.artifacts/releases/v0.9.11
./scripts/verify-release.sh 0.9.11 stable ./.artifacts/releases/v0.9.11
```
The builder selects the runtime payload declared in `config.json`; it does not
@@ -101,13 +106,17 @@ must provide Git, Bash, curl, jq, Python 3.11 or newer, tar, gzip, coreutils
uses `actions/checkout@v4` and checks that the checked-out commit is the exact
commit referenced by the annotated tag.
The tracked runner template and local setup instructions are in
[`../infra/gitea-runner/`](../infra/gitea-runner/). Its `.env`, registration
token, and persistent state stay outside source control.
Configure these protected Actions secrets:
- `RELEASE_TOKEN`: a dedicated token with only the repository release and
repository-content write permissions needed to create releases, upload
attachments, and update signed channel metadata.
- `MANIFEST_SIGNING_KEY`: the private key corresponding to
`ptlinux/launcher-update-public.pem`.
`src/ptlinux/launcher-update-public.pem`.
Secrets are available only to trusted tag and release-status workflows. Do not
add the release secrets to pull-request workflows. The signing key is written under

View File

@@ -27,7 +27,8 @@
"version_mirrors": [
"pyproject.toml"
],
"version_source": "ptlinux/__init__.py",
"version_source": "src/ptlinux/__init__.py",
"packaged_version_path": "ptlinux/__init__.py",
"payload": {
"python_trees": [
{
@@ -35,61 +36,80 @@
"desktop_entry.py",
"launcher_wrapper.py"
],
"source": "ptlinux"
"source": "src/ptlinux",
"destination": "ptlinux"
}
],
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem"
"source": "src/ptlinux/launcher-update-public.pem",
"destination": "ptlinux/launcher-update-public.pem"
},
{
"mode": "0644",
"path": "ptdiag/__init__.py"
"source": "src/ptdiag/__init__.py",
"destination": "ptdiag/__init__.py"
},
{
"mode": "0644",
"path": "ptdiag/assbin.py"
"source": "src/ptdiag/assbin.py",
"destination": "ptdiag/assbin.py"
},
{
"mode": "0644",
"path": "ptdiag/logs.py"
"source": "src/ptdiag/logs.py",
"destination": "ptdiag/logs.py"
},
{
"mode": "0644",
"path": "ptlinux/native/pt-identity-probe.exe"
"source": "src/ptlinux/native/pt-identity-probe.exe",
"destination": "ptlinux/native/pt-identity-probe.exe"
},
{
"mode": "0644",
"source": "src/ptlinux/native/ptr-bcrypt-proxy.dll",
"destination": "ptlinux/native/ptr-bcrypt-proxy.dll"
},
{
"mode": "0755",
"path": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
"source": "src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64",
"destination": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.README"
"source": "native/gamescope/gamescope-compat.README",
"destination": "ptlinux/native/gamescope-compat.README"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.copyright"
"source": "native/gamescope/gamescope-compat.copyright",
"destination": "ptlinux/native/gamescope-compat.copyright"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-lazy-output-images.patch"
"source": "native/gamescope/patches/gamescope-lazy-output-images.patch",
"destination": "ptlinux/native/gamescope-lazy-output-images.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-live-resize.patch"
"source": "native/gamescope/patches/gamescope-wayland-live-resize.patch",
"destination": "ptlinux/native/gamescope-wayland-live-resize.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-letterbox-backing.patch"
"source": "native/gamescope/patches/gamescope-wayland-letterbox-backing.patch",
"destination": "ptlinux/native/gamescope-wayland-letterbox-backing.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch"
"source": "native/gamescope/patches/gamescope-wayland-libdecor-input-scope.patch",
"destination": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-keep-nested-windows-visible.patch"
"source": "native/gamescope/patches/gamescope-keep-nested-windows-visible.patch",
"destination": "ptlinux/native/gamescope-keep-nested-windows-visible.patch"
}
]
},
@@ -97,12 +117,76 @@
"release.json",
"ptlinux/__init__.py",
"ptlinux/__main__.py",
"ptlinux/application/__init__.py",
"ptlinux/application/common.py",
"ptlinux/application/commands/__init__.py",
"ptlinux/application/commands/health.py",
"ptlinux/application/commands/installation.py",
"ptlinux/application/commands/launcher_update.py",
"ptlinux/application/commands/play.py",
"ptlinux/application/commands/settings.py",
"ptlinux/cli.py",
"ptlinux/diagnostics/__init__.py",
"ptlinux/diagnostics/crash_dumps.py",
"ptlinux/diagnostics/native_logs.py",
"ptlinux/game/__init__.py",
"ptlinux/game/arguments.py",
"ptlinux/game/compatibility/__init__.py",
"ptlinux/game/compatibility/assbin.py",
"ptlinux/game/depot/__init__.py",
"ptlinux/game/depot/catalogs.py",
"ptlinux/game/depot/filesystem.py",
"ptlinux/game/depot/integrity.py",
"ptlinux/game/depot/manifest.py",
"ptlinux/game/depot/models.py",
"ptlinux/game/depot/transfer.py",
"ptlinux/game/ptr_compat.py",
"ptlinux/gui_bootstrap.py",
"ptlinux/launcher_updates/__init__.py",
"ptlinux/launcher_updates/activation.py",
"ptlinux/launcher_updates/archive.py",
"ptlinux/launcher_updates/discovery.py",
"ptlinux/launcher_updates/models.py",
"ptlinux/launcher_updates/parsing.py",
"ptlinux/launcher_updates/rollback.py",
"ptlinux/launcher_updates/staging.py",
"ptlinux/self_update.py",
"ptlinux/telemetry/__init__.py",
"ptlinux/telemetry/dependencies.py",
"ptlinux/telemetry/environment.py",
"ptlinux/telemetry/errors.py",
"ptlinux/telemetry/extended.py",
"ptlinux/telemetry/graphics.py",
"ptlinux/telemetry/identity.py",
"ptlinux/telemetry/payload.py",
"ptlinux/telemetry/privacy.py",
"ptlinux/telemetry/probes.py",
"ptlinux/telemetry/runtime.py",
"ptlinux/telemetry/service.py",
"ptlinux/telemetry/transport.py",
"ptlinux/telemetry_environment.py",
"ptlinux/ui/components/content_view.py",
"ptlinux/ui/components/preloader.py",
"ptlinux/ui/components/startup_preloader.py",
"ptlinux/ui/controllers/__init__.py",
"ptlinux/ui/controllers/content.py",
"ptlinux/ui/controllers/events.py",
"ptlinux/ui/controllers/installation.py",
"ptlinux/ui/controllers/launcher_update.py",
"ptlinux/ui/controllers/operation.py",
"ptlinux/ui/controllers/play.py",
"ptlinux/ui/dialogs/debug.py",
"ptlinux/ui/dialogs/game_preloader.py",
"ptlinux/ui/viewmodels/debug.py",
"ptlinux/update_resolver.py",
"ptlinux/launcher-update-public.pem"
"ptlinux/launcher-update-public.pem",
"ptlinux/native/ptr-bcrypt-proxy.dll",
"ptdiag/__init__.py",
"ptdiag/assbin.py",
"ptdiag/logs.py"
],
"exclusions": [
".artifacts",
".git",
".gitea",
".idea",
@@ -133,8 +217,6 @@
"ptlinux/launcher-update-public.pem"
],
"preflight": [
"\"$PTLINUX_PYTHON\" -m compileall -q ptlinux ptdiag scripts release_tests",
"\"$PTLINUX_PYTHON\" -m unittest discover -s release_tests -p 'test_*.py'",
"\"$PTLINUX_PYTHON\" -m unittest discover -s tests -p 'test_*.py'"
"\"$PTLINUX_PYTHON\" -m compileall -q src/ptlinux src/ptdiag scripts"
]
}

5
release/notes/1.1.3.md Normal file
View File

@@ -0,0 +1,5 @@
- Improved game-window startup handling and focus tracking.
- Fixed Gamescope cleanup after game exit or interrupted startup.
- Corrected exit reporting when Gamescope closes without a recorded game status.
- Kept the launcher visible when game-window monitoring is unavailable.
- Added bounded local window diagnostics and preserved shutdown evidence in noisy logs.

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "1.1.2"
__version__ = "1.1.3"

View File

@@ -31,6 +31,7 @@ from ...umu import UmuBootstrapResult
from ...window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticLog,
)
@@ -82,6 +83,11 @@ def play_locked(
and bool(getattr(telemetry, "enabled", True))
)
launch_session_id = uuid4().hex if window_diagnostics_active else None
window_diagnostic_log = (
WindowDiagnosticLog(paths.logs / f"windows-{launch_session_id}.jsonl", launch_session_id)
if launch_session_id is not None
else None
)
launch_session_metadata = (
{"launch_session_id": launch_session_id}
if launch_session_id is not None
@@ -218,6 +224,7 @@ def play_locked(
# CommandRunner keeps a bounded output tail. Preserve readiness here so a
# long, noisy session cannot evict the evidence and become a false failure.
observed_readiness_signal: str | None = None
observed_gamescope_exit: dict[str, object] = {}
launch_success_reported = False
reported_runtime_warnings: set[str] = set()
if telemetry is not None and not dry_run:
@@ -245,6 +252,11 @@ def play_locked(
"""Timestamp launch success when the supervisor first confirms readiness."""
nonlocal observed_readiness_signal, launch_success_reported
if "gamescope exited without a recorded game status" in line.casefold():
# Retain supervisor evidence even if noisy child cleanup later evicts
# this line from CommandRunner's bounded stderr capture.
with launch_success_lock:
observed_gamescope_exit.update(gamescope_exit_summary(line))
presentation_failure = api._gamescope_presentation_failure(line)
if telemetry is not None and not dry_run and presentation_failure is not None:
with launch_success_lock:
@@ -271,6 +283,9 @@ def play_locked(
diagnostic_session_id = window_diagnostic.get("session_id")
if launch_session_id is None or diagnostic_session_id != launch_session_id:
return line
if window_diagnostic_log is not None:
with launch_success_lock:
window_diagnostic_log.record(window_diagnostic)
if telemetry is not None and window_diagnostics_active:
with launch_success_lock:
telemetry.send_event(
@@ -386,6 +401,7 @@ def play_locked(
game_started_ns,
)
runtime_issues = api._runtime_issue_summary(outcome.stderr)
runtime_issues.update(observed_gamescope_exit)
retained_readiness_signal = api._game_readiness_signal(outcome.stderr)
game_readiness_signal = observed_readiness_signal or retained_readiness_signal
game_window_ready = game_readiness_signal is not None
@@ -400,7 +416,8 @@ def play_locked(
)
if recommended_comparison is not None:
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
clean_exit = outcome.returncode == 0 and current_game_crash is None
game_status_missing = runtime_issues.get("gamescope_child_status_missing") is True
clean_exit = outcome.returncode == 0 and current_game_crash is None and not game_status_missing
session_issue = game_log_issue_reason(current_game_log)
launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None:
@@ -432,6 +449,11 @@ def play_locked(
"current_game_log": current_game_log,
"current_game_crash": current_game_crash,
"runtime_issues": runtime_issues,
"window_diagnostics_log": (
str(window_diagnostic_log.path)
if window_diagnostic_log is not None and window_diagnostic_log.path.is_file()
else None
),
}
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
if telemetry is not None and not dry_run:
@@ -449,8 +471,16 @@ def play_locked(
"session_duration_class": api._session_duration_class(game_runtime_seconds),
"runtime": selection.kind,
"realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind),
**api._game_exit_metadata(
outcome.returncode, "gamescope" if game_status_missing else selection.kind,
),
}
if game_status_missing:
common_game_metadata.update({
"game_exit_status_available": False,
"exit_status_source": "gamescope_supervisor",
"gamescope_exit_code": runtime_issues.get("gamescope_exit_code"),
})
if not clean_exit or session_issue is not None:
common_game_metadata["failure_reason"] = game_failure_reason(
outcome,
@@ -509,6 +539,8 @@ def play_locked(
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
else gamescope_exit_message(runtime_issues)
if game_status_missing
else f"Game client exited with status {outcome.returncode}"
),
metadata={
@@ -531,6 +563,8 @@ def play_locked(
exit_message = (
"Game client exited after logging Direct3D resource exhaustion"
)
elif game_status_missing:
exit_message = gamescope_exit_message(runtime_issues)
elif common_game_metadata.get("signal_inferred") is True:
signal_wrapper = (
"Proton" if selection.kind == "proton7" else "UMU"
@@ -580,7 +614,9 @@ def play_locked(
runtime_issues,
current_game_crash,
)
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
if not clean_exit and report["window_diagnostics_log"] is not None:
print(f"Window diagnostics: {report['window_diagnostics_log']}", file=sys.stderr)
return 0 if clean_exit else 1
def session_duration_class(duration_seconds: float) -> str:
@@ -686,6 +722,11 @@ def game_failure_reason(
return "client_crash_dump"
if outcome.timed_out:
return "session_timeout"
if (
exit_metadata.get("game_exit_status_available") is False
or "gamescope exited without a recorded game status" in outcome.stderr.casefold()
):
return "gamescope_game_status_missing"
if "signal" in exit_metadata:
return "process_signal"
return "nonzero_exit"
@@ -923,6 +964,20 @@ def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]
return {key: report[key] for key in keys if key in report}
def gamescope_exit_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
gamescope_status = re.search(
r"gamescope exited without a recorded game status \(compositor status (-?\d{1,3})\)",
folded,
)
return {
"gamescope_child_status_missing": (
"gamescope exited without a recorded game status" in folded
),
"gamescope_exit_code": int(gamescope_status[1]) if gamescope_status is not None else None,
}
def runtime_issue_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
futex_message = "the futex facility returned an unexpected error code"
@@ -965,9 +1020,7 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]:
"gamescope_forced_cleanup": (
"gamescope did not stop gracefully; forcing cleanup" in folded
),
"gamescope_child_status_missing": (
"gamescope exited without a recorded game status" in folded
),
**gamescope_exit_summary(stderr),
"proton_game_drive_warning": "unable to use parent for game drive" in folded,
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
@@ -995,6 +1048,9 @@ def game_window_was_ready(stderr: str) -> bool:
def game_readiness_signal(output: str) -> str | None:
folded = output.casefold()
# Older supervisors used a readiness prefix without observing a window.
# Preserve their UI fallback without treating it as successful presentation.
folded = folded.replace("game window ready: unmanaged ", "unmonitored game process: ")
for signal_name, marker in (
("window_ready", "game window ready:"),
("rendered_frame", "rendered game frame:"),
@@ -1035,6 +1091,14 @@ def window_diagnostic_severity(diagnostic: dict[str, object]) -> str:
return "debug"
def gamescope_exit_message(runtime_issues: dict[str, object]) -> str:
message = "Game session ended before the game reported its exit status"
status = runtime_issues.get("gamescope_exit_code")
if isinstance(status, int):
message += f" (Gamescope status {status})"
return message
def print_game_outcome(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
@@ -1063,6 +1127,8 @@ def print_game_outcome(
"exhaustion.",
file=sys.stderr,
)
elif runtime_issues.get("gamescope_child_status_missing") is True:
print(gamescope_exit_message(runtime_issues) + ".", file=sys.stderr)
elif outcome.returncode == 0:
print("Game client exited normally.")
elif outcome.returncode is None:

View File

@@ -0,0 +1,53 @@
"""Parse the publisher's Windows argument string without invoking a shell."""
from __future__ import annotations
def split_windows_arguments(command_line: str) -> tuple[str, ...]:
"""Decode MS C runtime argument quoting (the executable is supplied separately).
Backslashes are literal except before double quotes; single quotes and shell
metacharacters are ordinary characters. Empty quoted arguments are retained.
See https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments
"""
if any(ord(character) < 32 and character != "\t" for character in command_line):
raise ValueError("launch arguments contain control characters")
arguments: list[str] = []
index = 0
while index < len(command_line):
while index < len(command_line) and command_line[index] in " \t":
index += 1
if index == len(command_line):
break
argument: list[str] = []
quoted = False
while index < len(command_line):
character = command_line[index]
if character in " \t" and not quoted:
break
if character == "\\":
start = index
while index < len(command_line) and command_line[index] == "\\":
index += 1
count = index - start
if index == len(command_line) or command_line[index] != '"':
argument.append("\\" * count)
continue
argument.append("\\" * (count // 2))
if count % 2:
argument.append('"')
index += 1
continue
character = '"'
if character == '"':
if quoted and command_line[index:index + 2] == '""':
argument.append('"')
index += 2
continue
quoted = not quoted
else:
argument.append(character)
index += 1
arguments.append("".join(argument))
return tuple(arguments)

View File

@@ -0,0 +1,121 @@
"""Recover a usable GUI interpreter and report missing Tk without a Tk window."""
from __future__ import annotations
from importlib import import_module
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Sequence
class GuiDependencyError(RuntimeError):
"""The current Python installation cannot provide the dashboard toolkit."""
def tkinter_path() -> str | None:
"""Check the Python package, extension, and Tcl data without opening a display."""
try:
tk = import_module("tkinter")
import_module("tkinter.ttk")
import_module("tkinter.filedialog")
tk.Tcl()
return tk.__file__ or "available"
except (ImportError, OSError, RuntimeError):
return None
except Exception as exc:
# TclError is only available after a successful tkinter import.
if type(exc).__name__ == "TclError":
return None
raise
def recover_gui_python(arguments: Sequence[str]) -> bool:
"""Use the distribution's Python if a PATH/venv Python has no usable Tk."""
if tkinter_path() is not None:
return True
if os.environ.get("PTLINUX_PYTHON"):
return False
candidate = Path("/usr/bin/python3")
if not candidate.is_file() or os.environ.get("PTLINUX_GUI_PYTHON_RETRIED") == "1":
return False
# A venv may share the same binary but have different package search paths.
if str(candidate) == sys.executable and sys.prefix == sys.base_prefix:
return False
probe = (
"import sys; assert sys.version_info >= (3, 11); "
"import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()"
)
try:
result = subprocess.run(
[str(candidate), "-I", "-B", "-c", probe],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5, check=False,
)
if result.returncode != 0:
return False
environment = os.environ.copy()
# Preserve the selected release when restarting from python -m or a
# desktop entry. Ignore Python-home settings belonging to the failed SDK.
environment.pop("PYTHONHOME", None)
environment["PYTHONPATH"] = str(Path(__file__).resolve().parent.parent)
environment["PYTHONSAFEPATH"] = "1"
environment["PYTHONDONTWRITEBYTECODE"] = "1"
environment["PTLINUX_GUI_PYTHON_RETRIED"] = "1"
os.execve(str(candidate), [str(candidate), "-B", "-P", "-m", "ptlinux", *arguments], environment)
except (OSError, subprocess.TimeoutExpired):
return False
return False
def missing_tk_message() -> str:
from .system_identity import detect_distribution_id
distribution = detect_distribution_id()
if distribution in {"ubuntu", "debian", "linuxmint", "pop", "elementary", "zorin"}:
repair = "Install python3-tk with your package manager (sudo apt install python3-tk)."
elif distribution in {"fedora", "nobara"}:
repair = "Install python3-tkinter with your package manager."
elif distribution in {"arch", "cachyos", "endeavouros", "garuda", "manjaro"}:
repair = "Install tk with your package manager."
elif distribution == "steamos":
repair = "Use a Python installation that includes tkinter and Tcl/Tk."
else:
repair = "Install the Tk package matching your Python version with your distribution's package manager."
return (
f"Python Tk is unavailable for {sys.executable} (Python {sys.version_info.major}.{sys.version_info.minor}). "
f"{repair} If you selected a custom Python, install its matching Tk support or "
"start with PTLINUX_PYTHON=/usr/bin/python3. "
"The pristontale-eu command-line interface remains available."
)
def show_gui_dependency_error(message: str) -> None:
"""Give desktop launches an actionable message even when Tk cannot import."""
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return
for name, arguments in (
("kdialog", ["--title", "PristonTale EU", "--error", message]),
("zenity", ["--error", "--title=PristonTale EU", "--text", message]),
("notify-send", ["--urgency=critical", "PristonTale EU", message]),
):
executable = shutil.which(name)
if executable is None:
continue
try:
result = subprocess.run(
[executable, *arguments], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=60, check=False,
)
if result.returncode == 0:
return
except subprocess.TimeoutExpired:
return
except OSError:
continue

View File

@@ -2605,7 +2605,7 @@ def run_game_window(
raise
if mapper is None:
print(
f"Game window ready: unmanaged {window_title}",
f"Game window monitoring unavailable; process started: {window_title}",
file=sys.stderr,
flush=True,
)

View File

@@ -336,8 +336,10 @@ def _supervised_command(
str(command[0]),
"--backend",
backend,
"--keep-alive",
]
# The child records its status before exiting. Leave Gamescope's normal
# child-exit shutdown enabled so a reporting failure (for example ENOSPC)
# cannot strand an empty compositor with no status for us to observe.
if backend == "sdl" or force_composition:
result.append("--force-composition")
result.extend(map(str, command[1:]))
@@ -611,20 +613,19 @@ def _wait_for_optimized_gamescope(
child_status = (
_read_child_status(status_file) if status_file is not None else None
)
if status_file is not None and child_status is None:
if child_status is None:
# A compositor can exit with 0 when its host window closes,
# even though its nested game was killed. Only the game's
# recorded status can establish a clean game exit.
print(
"Gamescope exited without a recorded game status; "
"the game session was interrupted.",
"Gamescope exited without a recorded game status "
f"(compositor status {gamescope_status}); "
"the game's exit result is unknown.",
file=sys.stderr,
flush=True,
)
return _process_exit_status(gamescope_status) or 1
return _process_exit_status(
gamescope_status if child_status is None else child_status
)
return _process_exit_status(child_status)
time.sleep(CHILD_STATUS_POLL_SECONDS)
@@ -880,104 +881,104 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
diagnostics.close(process_started=False, backend=backend)
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
if kde_wayland_xwayland_fallback:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"SDL host window through XWayland to avoid KWin presentation, "
"fullscreen, and fractional-scaling failures.",
file=sys.stderr,
flush=True,
)
elif cinnamon_wayland_xwayland_fallback:
print(
"Cinnamon Wayland compatibility mode active: Gamescope is using "
"its resizable SDL host through XWayland so Muffin provides the "
"native minimize, maximize, and close controls.",
file=sys.stderr,
flush=True,
)
if backend == "wayland" and not force_composition:
message = (
"Gamescope resize optimization active: native Wayland direct "
"presentation keeps live host-window changes off the game surface."
)
if using_bundled_gamescope:
message += (
" Launcher-local lazy-output, live-resize, letterbox backing, "
"decoration-input, and login-window visibility fixes are active."
)
print(message, file=sys.stderr, flush=True)
elif backend == "wayland" and kde_session:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"composed native fallback because no XWayland display is available.",
file=sys.stderr,
flush=True,
)
elif backend == "wayland":
print(
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
"backing. Forced composition prevents flicker, but live resizing may "
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
file=sys.stderr,
flush=True,
)
elif mapper is not None:
try:
if diagnostic_sink is None:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
)
else:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
diagnostics=diagnostic_sink,
)
except Exception as exc:
increments = None
diagnostics.record(
"host_scaling_configuration_failed",
error_type=type(exc).__name__,
)
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
if increments is not None:
print(
"Gamescope resize optimization active: the host window remains "
"composited and size changes use "
f"{increments[0]}x{increments[1]} pixel steps.",
file=sys.stderr,
flush=True,
)
elif process.poll() is None:
diagnostics.record("host_scaling_unavailable")
print(
"Warning: Gamescope's host window could not be resize-tuned; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
else:
diagnostics.record("host_x11_monitor_unavailable")
print(
"Gamescope is using its SDL presentation fallback; host X11 resize "
"tuning is unavailable.",
file=sys.stderr,
flush=True,
)
try:
if kde_wayland_xwayland_fallback:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"SDL host window through XWayland to avoid KWin presentation, "
"fullscreen, and fractional-scaling failures.",
file=sys.stderr,
flush=True,
)
elif cinnamon_wayland_xwayland_fallback:
print(
"Cinnamon Wayland compatibility mode active: Gamescope is using "
"its resizable SDL host through XWayland so Muffin provides the "
"native minimize, maximize, and close controls.",
file=sys.stderr,
flush=True,
)
if backend == "wayland" and not force_composition:
message = (
"Gamescope resize optimization active: native Wayland direct "
"presentation keeps live host-window changes off the game surface."
)
if using_bundled_gamescope:
message += (
" Launcher-local lazy-output, live-resize, letterbox backing, "
"decoration-input, and login-window visibility fixes are active."
)
print(message, file=sys.stderr, flush=True)
elif backend == "wayland" and kde_session:
print(
"KDE Wayland compatibility mode active: Gamescope is using its "
"composed native fallback because no XWayland display is available.",
file=sys.stderr,
flush=True,
)
elif backend == "wayland":
print(
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
"backing. Forced composition prevents flicker, but live resizing may "
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
file=sys.stderr,
flush=True,
)
elif mapper is not None:
try:
if diagnostic_sink is None:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
)
else:
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
diagnostics=diagnostic_sink,
)
except Exception as exc:
increments = None
diagnostics.record(
"host_scaling_configuration_failed",
error_type=type(exc).__name__,
)
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
if increments is not None:
print(
"Gamescope resize optimization active: the host window remains "
"composited and size changes use "
f"{increments[0]}x{increments[1]} pixel steps.",
file=sys.stderr,
flush=True,
)
elif process.poll() is None:
diagnostics.record("host_scaling_unavailable")
print(
"Warning: Gamescope's host window could not be resize-tuned; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
else:
diagnostics.record("host_x11_monitor_unavailable")
print(
"Gamescope is using its SDL presentation fallback; host X11 resize "
"tuning is unavailable.",
file=sys.stderr,
flush=True,
)
return _wait_for_optimized_gamescope(
process,
status_file,

View File

@@ -208,6 +208,10 @@ def start_operation_process(
)
events.put(("game_started", game_client_id))
if game_client_id is not None and line.startswith(
("Game window monitoring unavailable; process started:", "Game window ready: unmanaged ")
):
events.put(("game_window_unmonitored", game_client_id))
elif game_client_id is not None and line.startswith(
("Game window ready:", "Rendered game frame:")
):
events.put(("game_window_ready", game_client_id))

View File

@@ -1012,6 +1012,7 @@ def run_dashboard(paths: AppPaths) -> None:
ready: bool,
returncode: int | None = None,
cancelled: bool = False,
window_unmonitored: bool = False,
) -> None:
nonlocal active_game_launch_id
nonlocal operation_active, game_window_active
@@ -1037,6 +1038,9 @@ def run_dashboard(paths: AppPaths) -> None:
status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.")
dashboard_chrome.minimize()
elif window_unmonitored:
status.set(f"Client {client_id} started; window readiness could not be checked.")
progress_text.set("Select the game window to continue, or see the activity log for details.")
elif cancelled:
root.lift()
status.set("Game launch cancelled.")
@@ -1166,6 +1170,19 @@ def run_dashboard(paths: AppPaths) -> None:
game_window_active = True
release_game_launch_controls(value, ready=True)
def handle_game_window_unmonitored_event(value: object) -> None:
nonlocal game_window_active
if not (
isinstance(value, int)
and not game_launch_supervisors.is_cancel_requested(value)
):
return
if game_clients.mark_running(value):
game_window_active = True
# Release the modal preloader without claiming a visible window or
# minimizing the dashboard before the user can inspect startup errors.
release_game_launch_controls(value, ready=False, window_unmonitored=True)
def handle_game_done_event(value: object) -> None:
nonlocal game_window_active
if not (
@@ -1366,6 +1383,7 @@ def run_dashboard(paths: AppPaths) -> None:
"game_operation_status": handle_game_operation_status_event,
"game_started": handle_game_started_event,
"game_window_ready": handle_game_window_ready_event,
"game_window_unmonitored": handle_game_window_unmonitored_event,
"game_done": handle_game_done_event,
"broadcast": handle_broadcast_event,
"server_status_check": handle_server_status_event,

View File

@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping, Sequence
import json
import math
import os
from pathlib import Path
import re
import sys
import time
@@ -33,6 +34,46 @@ def window_diagnostics_enabled(
return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES
class WindowDiagnosticLog:
"""Retain bounded supervisor evidence independently of remote telemetry."""
def __init__(self, path: Path, session_id: str) -> None:
self.path = path
self.session_id = session_id
self._samples: Counter[str] = Counter()
self._summaries: set[str] = set()
self._failed = False
def record(self, payload: dict[str, object]) -> None:
if self._failed or payload.get("session_id") != self.session_id:
return
component = payload.get("component")
if not isinstance(component, str) or component not in WINDOW_DIAGNOSTIC_COMPONENTS:
return
if payload.get("diagnostic") == "session_summary":
if component in self._summaries:
return
self._summaries.add(component)
elif self._samples[component] >= WINDOW_DIAGNOSTIC_EVENT_LIMIT:
return
else:
self._samples[component] += 1
try:
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(
self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600,
)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(encoded + "\n")
except (OSError, TypeError, ValueError):
# Logging must never interrupt the game or repeatedly fill stderr.
self._failed = True
print("Warning: window diagnostics could not be saved locally.", file=sys.stderr)
class WindowDiagnosticRecorder:
"""Emit a few samples while retaining aggregate counts for session shutdown."""

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import queue
import subprocess
import unittest
from unittest.mock import MagicMock
from ptlinux.ui.controllers.operation import start_operation_process
from ptlinux.ui.viewmodels.downloader import ProgressCoalescer
class _InlineThread:
def __init__(self, *, target, name: str, daemon: bool) -> None:
self._target = target
self.name = name
self.daemon = daemon
def start(self) -> None:
self._target()
class _Process:
def __init__(self, lines: list[str], returncode: int = 0, pid: int = 4321) -> None:
self.stdout = iter(lines)
self.returncode = returncode
self.pid = pid
def wait(self) -> int:
return self.returncode
def _drain(events: queue.Queue[tuple[str, object]]) -> list[tuple[str, object]]:
values: list[tuple[str, object]] = []
while not events.empty():
values.append(events.get_nowait())
return values
class DashboardOperationControllerTests(unittest.TestCase):
def test_routes_status_progress_output_and_completion(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
progress = ProgressCoalescer()
registry = MagicMock()
process = _Process(
[
'STATUS {"step": 1, "total": 3, "message": "Starting"}\n',
'PROGRESS {"phase": "downloading", "bytes_completed": 8}\n',
"plain output\n",
"STATUS invalid\n",
],
returncode=7,
)
seen_process_args: list[tuple[list[str], dict[str, object]]] = []
def process_factory(command: list[str], **kwargs: object) -> _Process:
seen_process_args.append((command, kwargs))
return process
start_operation_process(
["launcher", "update"],
events=events,
progress_updates=progress,
game_launch_supervisors=registry,
child_environment=lambda: {"ROLE": "operation"},
interrupt_supervisor=MagicMock(),
process_factory=process_factory,
thread_factory=_InlineThread,
)
self.assertEqual(
_drain(events),
[
(
"operation_status",
{"step": 1, "total": 3, "message": "Starting"},
),
("output", "plain output\n"),
("output", "STATUS invalid\n"),
("done", 7),
],
)
self.assertEqual(
progress.take(),
{"phase": "downloading", "bytes_completed": 8},
)
self.assertEqual(seen_process_args[0][0], ["launcher", "update"])
self.assertEqual(seen_process_args[0][1]["env"], {"ROLE": "operation"})
self.assertEqual(seen_process_args[0][1]["stdout"], subprocess.PIPE)
self.assertEqual(seen_process_args[0][1]["stderr"], subprocess.STDOUT)
def test_routes_game_readiness_and_cancellation_race(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
registry.attach.return_value = True
process = _Process(
[
"Game window created; preparing it\n",
"Game window ready: PristonTale EU\n",
],
)
interrupt = MagicMock(return_value=True)
start_operation_process(
["launcher", "play"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=interrupt,
game_client_id=3,
process_factory=lambda *_args, **_kwargs: process,
thread_factory=_InlineThread,
)
kinds = [kind for kind, _value in _drain(events)]
self.assertEqual(
kinds,
[
"game_supervisor_started",
"output",
"output",
"game_operation_status",
"game_started",
"output",
"game_window_ready",
"game_done",
],
)
registry.attach.assert_called_once_with(3, process)
registry.detach.assert_called_once_with(3, process)
interrupt.assert_called_once_with(process)
def test_unmonitored_process_releases_controls_without_a_window_ready_event(self) -> None:
for line in (
"Game window monitoring unavailable; process started: PristonTale EU\n",
"Game window ready: unmanaged PristonTale EU\n",
):
with self.subTest(line=line):
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
registry.attach.return_value = False
process = _Process([line], returncode=1)
start_operation_process(
["launcher", "play"], events=events,
progress_updates=ProgressCoalescer(), game_launch_supervisors=registry,
child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=3,
process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread,
)
result = _drain(events)
self.assertIn(("game_window_unmonitored", 3), result)
self.assertNotIn(("game_window_ready", 3), result)
self.assertEqual(result[-1], ("game_done", (3, 1)))
def test_cancellation_before_process_start_skips_the_child(self) -> None:
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = True
process_factory = MagicMock()
start_operation_process(
["launcher", "play"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=MagicMock(),
game_client_id=4,
process_factory=process_factory,
thread_factory=_InlineThread,
)
self.assertEqual(
_drain(events),
[
(
"output",
"[Client 4] Launch cancelled before the game supervisor started.\n",
),
("game_done", (4, 130)),
],
)
process_factory.assert_not_called()
def test_process_start_failure_uses_the_matching_completion_event(self) -> None:
for client_id, done_event in ((None, ("done", 1)), (5, ("game_done", (5, 1)))):
with self.subTest(client_id=client_id):
events: queue.Queue[tuple[str, object]] = queue.Queue()
registry = MagicMock()
registry.is_cancel_requested.return_value = False
def fail_to_start(*_args: object, **_kwargs: object) -> _Process:
raise OSError("missing executable")
start_operation_process(
["missing"],
events=events,
progress_updates=ProgressCoalescer(),
game_launch_supervisors=registry,
child_environment=dict,
interrupt_supervisor=MagicMock(),
game_client_id=client_id,
process_factory=fail_to_start,
thread_factory=_InlineThread,
)
emitted = _drain(events)
self.assertIn(done_event, emitted)
self.assertIn("Could not start operation", str(emitted[0][1]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,594 @@
from dataclasses import asdict, replace
import ctypes
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.application.commands.play import (
ensure_portable_umu_for_play,
play_locked,
runtime_issue_summary,
)
from ptlinux.cli import _load_or_default_settings
from ptlinux.depot import DepotError, DepotLaunchCatalog, DepotStartOption
from ptlinux.game.arguments import split_windows_arguments
from ptlinux.game.depot.catalogs import select_depot_start_option
from ptlinux.game.depot.manifest import parse_start_options
from ptlinux.game.runtime import (
RuntimeErrorPT,
RuntimeSelection,
game_runtime_ready,
prepare_game_runtime,
run_game,
run_runtime_prefix_command,
runtime_settings_for_realm,
select_runtime,
)
from ptlinux.game.runtime_view import (
KNOWN_SOURCE_SHA256,
OverlayError,
_scene_action,
prepare_runtime_view,
select_scene_action,
)
from ptlinux.paths import AppPaths
from ptlinux.presentation import game_window
from ptlinux.settings import (
Settings,
ensure_settings,
load_settings,
reset_settings,
save_settings,
update_settings,
)
from ptlinux.telemetry.runtime import _selected_prefix, _wine_command
class WindowsArgumentTests(unittest.TestCase):
def test_preserves_windows_paths_quotes_empty_arguments_and_shell_literals(self) -> None:
arguments = (
"PTEU_REBORN", r"C:\game\data", "Babel's Realm", "", "a b",
'a"b', "trailing\\", r"$(touch /tmp/should-not-exist)", "a;b", "%TEMP%",
)
self.assertEqual(split_windows_arguments(subprocess.list2cmdline(arguments)), arguments)
def test_windows_quoting_examples(self) -> None:
for command, expected in (
('"a b c" d e', ("a b c", "d", "e")),
(r'a\\\b d"e f"g h', (r"a\\\b", "de fg", "h")),
(r'a\\\"b c d', ('a\\"b', "c", "d")),
(r'a\\\\"b c" d e', (r"a\\b c", "d", "e")),
('a"b"" c d', ('ab" c d',)),
("Babel's Realm", ("Babel's", "Realm")),
(" \t ", ()),
):
with self.subTest(command=command):
self.assertEqual(split_windows_arguments(command), expected)
def test_manifest_allows_no_arguments_and_keeps_publisher_default(self) -> None:
options, default = parse_start_options(
[
{"Name": "Babel", "Path": "Game.exe", "Arguments": ""},
{"Name": "New Realm", "Path": "New.exe"},
],
"New Realm",
)
self.assertEqual(options[0].arguments, "")
self.assertEqual(options[1].arguments, "")
catalog = DepotLaunchCatalog(10, options, default)
self.assertEqual(select_depot_start_option(catalog, "Removed Realm"), options[1])
class RuntimeDefaultMigrationTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.config.mkdir(parents=True)
def write_settings(self, value: dict[str, object]) -> None:
self.paths.settings_file.write_text(json.dumps(value), encoding="utf-8")
def test_new_and_reset_installations_use_ge_proton(self) -> None:
self.assertEqual(Settings().runtime, "ge-proton")
self.assertEqual(load_settings(self.paths).runtime, "ge-proton")
self.assertFalse(self.paths.settings_file.exists())
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
save_settings(self.paths, Settings(runtime="proton7"))
self.assertEqual(reset_settings(self.paths).runtime, "ge-proton")
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
def test_all_old_profiles_migrate_without_changing_other_preferences(self) -> None:
for runtime in ("auto", "proton7", "proton7-container", "umu", "ge-proton"):
with self.subTest(runtime=runtime):
old_settings = Settings(
runtime=runtime,
proton_path="/Steam Library/Proton 7.0/proton",
renderer="wined3d",
proton_sync="esync",
telemetry_enabled=False,
game_crash_dump_upload_enabled=False,
game_source="/Games/Publisher",
ui_scale="150",
borderless=True,
allow_window_resizing=True,
suppress_unresponsive_warnings=False,
download_speed_limit_mbps=4.0,
low_bandwidth_mode=True,
update_channel="prerelease",
)
self.write_settings({
**asdict(old_settings), "schema": 1,
"title_frame_default_migrated": True,
"future_setting": {"keep": True},
})
before = self.paths.settings_file.read_bytes()
expected = replace(old_settings, runtime="ge-proton")
self.assertEqual(load_settings(self.paths), expected)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(ensure_settings(self.paths), expected)
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
self.assertEqual(payload["future_setting"], {"keep": True})
self.assertEqual(payload["schema"], 1)
self.assertEqual(self.paths.settings_file.stat().st_mode & 0o777, 0o600)
def test_migration_is_persisted_once_and_later_manual_choices_survive(self) -> None:
self.write_settings({"runtime": "proton7", "ge_proton_default_migrated": False})
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
before = self.paths.settings_file.stat()
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
after = self.paths.settings_file.stat()
self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns))
for runtime in ("auto", "proton7", "proton7-container", "umu"):
with self.subTest(runtime=runtime):
update_settings(self.paths, runtime=runtime)
update_settings(self.paths, proton_sync="esync")
self.assertEqual(ensure_settings(self.paths).runtime, runtime)
self.assertEqual(load_settings(self.paths).runtime, runtime)
def test_unrelated_update_migrates_the_runtime(self) -> None:
self.write_settings({"runtime": "umu"})
settings = update_settings(self.paths, low_bandwidth_mode=True)
self.assertEqual(settings.runtime, "ge-proton")
self.assertTrue(settings.low_bandwidth_mode)
self.assertEqual(ensure_settings(self.paths), settings)
def test_explicit_runtime_choice_during_first_save_is_preserved(self) -> None:
for full_save in (False, True):
with self.subTest(full_save=full_save):
self.write_settings({"runtime": "auto"})
if full_save:
save_settings(self.paths, Settings(runtime="umu"))
else:
update_settings(self.paths, runtime="umu")
self.assertEqual(ensure_settings(self.paths).runtime, "umu")
def test_migration_keeps_the_ptr_override_and_ignores_its_path_for_babel(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
for realm in ("PTR Babel", "Test Babel"):
with self.subTest(realm=realm):
self.write_settings({"runtime": "proton7", "realm": realm, "proton_path": str(proton)})
settings = ensure_settings(self.paths)
self.assertEqual(settings.runtime, "ge-proton")
self.assertEqual(settings.realm, realm)
self.assertEqual(runtime_settings_for_realm(settings).runtime, "proton7")
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
ptr = select_runtime(settings, {"HOME": str(self.root)})
babel = select_runtime(replace(settings, realm="Babel"), {})
self.assertEqual(ptr.kind, "proton7")
self.assertEqual(ptr.proton_path, str(proton))
self.assertEqual(babel.kind, "ge-proton")
self.assertIsNone(babel.proton_path)
def test_invalid_runtime_and_marker_are_rejected_without_rewriting(self) -> None:
for value in (
{"runtime": "unsupported"}, {"runtime": None}, {"runtime": True},
{"ge_proton_default_migrated": "true"}, {"ge_proton_default_migrated": 1},
):
with self.subTest(value=value):
self.write_settings(value)
before = self.paths.settings_file.read_bytes()
with self.assertRaises(ValueError):
ensure_settings(self.paths)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
def test_default_cannot_silently_fall_back_to_legacy_proton(self) -> None:
with (
patch("ptlinux.game.runtime.find_umu_launcher", return_value=None),
patch("ptlinux.game.runtime.find_steam_proton7") as find_legacy,
):
with self.assertRaisesRegex(RuntimeErrorPT, "umu-run was not found"):
select_runtime(Settings(), {})
find_legacy.assert_not_called()
def test_default_bootstrap_error_does_not_fall_back_to_proton7(self) -> None:
api = MagicMock()
api.provision_umu.side_effect = RuntimeError("network unavailable")
with self.assertRaisesRegex(RuntimeError, "portable UMU.*network unavailable"):
ensure_portable_umu_for_play(api, self.paths, Settings(), dry_run=False)
api.select_runtime.assert_not_called()
def test_cli_migrates_existing_settings_and_dry_runs_remain_read_only(self) -> None:
self.write_settings({"runtime": "proton7"})
before = self.paths.settings_file.read_bytes()
self.assertEqual(_load_or_default_settings(self.paths, persist=False).runtime, "ge-proton")
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(_load_or_default_settings(self.paths, persist=True).runtime, "ge-proton")
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
class LaunchCompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.runtime_view.mkdir(parents=True)
(self.paths.runtime_view / "Game.exe").touch()
self.runner = MagicMock(dry_run=False)
self.selection = RuntimeSelection("ge-proton", "/usr/bin/umu-run", None, None)
self.host_check = patch("ptlinux.game.runtime._require_container_host")
self.host_check.start()
self.addCleanup(self.host_check.stop)
def test_recovers_legacy_renderer_option_and_persists_migration(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({"proton_path": "PROTON_USE_WINED3D=1"}))
settings = ensure_settings(self.paths)
self.assertIsNone(settings.proton_path)
self.assertEqual(settings.renderer, "wined3d")
self.assertEqual(load_settings(self.paths), settings)
self.assertIsNone(json.loads(self.paths.settings_file.read_text())["proton_path"])
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
selection = select_runtime(settings, {})
run_game(self.paths, settings, selection, self.runner, use_gamebox=False)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], "1")
self.assertIn("d3d9=b", environment["WINEDLLOVERRIDES"])
def test_does_not_execute_or_migrate_arbitrary_path_commands(self) -> None:
for value in ("PROTON_LOG=1", "env PROTON_USE_WINED3D=1 %command%"):
with self.subTest(value=value):
settings = Settings(runtime="auto", proton_path=value)
with self.assertRaisesRegex(ValueError, "filesystem path"):
save_settings(self.paths, settings)
with self.assertRaisesRegex(RuntimeErrorPT, "filesystem path"):
select_runtime(settings, {})
def test_existing_explicit_renderer_wins_over_misplaced_legacy_option(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({
"proton_path": "PROTON_USE_WINED3D=1", "renderer": "dxvk",
}))
self.assertEqual(ensure_settings(self.paths).renderer, "dxvk")
def test_proton_script_field_accepts_a_steam_proton_directory_with_spaces(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
selection = select_runtime(Settings(runtime="proton7", proton_path=str(proton.parent)), {})
self.assertEqual(selection.proton_path, str(proton))
def test_auto_selects_ge_across_distributions_and_keeps_explicit_umu(self) -> None:
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
for runtime in ("auto", "ge-proton"):
for distribution in ("ubuntu", "cachyos", "arch", "fedora"):
with self.subTest(runtime=runtime, distribution=distribution):
self.assertEqual(select_runtime(Settings(runtime=runtime), {}, distribution_id=distribution).kind, "ge-proton")
self.assertEqual(select_runtime(Settings(runtime="umu"), {}).kind, "umu")
def test_future_publisher_scene_is_copied_unchanged_without_old_texture_repair(self) -> None:
source = self.paths.source_game
for relative, content in (
("Game.exe", b"new executable"),
("game.dll", b"new dll"),
("game.ini", b"[Screen]\nWindowed=1\n"),
("game/maps/WhisperingVale.rovug", b"future publisher scene format"),
):
path = source / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
with patch("ptlinux.game.runtime_view.normalize_wrapped_assbin_textures") as repair:
result = prepare_runtime_view(self.paths, source)
self.assertEqual(result.scene_action, "upstream-unmodified-copy")
self.assertEqual(result.binary_action, "upstream-pair")
self.assertEqual(result.source_scene_sha256, result.runtime_scene_sha256)
self.assertEqual((source / "game/maps/WhisperingVale.rovug").read_bytes(), b"future publisher scene format")
repair.assert_not_called()
def test_unknown_scene_texture_count_does_not_trigger_legacy_patch(self) -> None:
self.assertEqual(_scene_action(MagicMock(file_sha256="new", embedded_textures=())), "upstream-unmodified-copy")
self.assertEqual(select_scene_action("new", [16, 16], [2, 2]), "upstream-unmodified-copy")
self.assertEqual(select_scene_action(KNOWN_SOURCE_SHA256, [16, 16], [2, 2]), "normalize-known-profile")
with self.assertRaises(OverlayError):
select_scene_action(KNOWN_SOURCE_SHA256, [16, 8], [2, 2])
def test_ge_setup_game_and_helpers_use_one_isolated_wow64_prefix(self) -> None:
self.runner.dry_run = True
prepare_game_runtime(self.paths, self.selection, self.runner)
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
run_runtime_prefix_command(self.paths, self.selection, self.runner, ["reg.exe", "query"])
self.assertEqual(self.runner.run.call_count, 3)
for call in self.runner.run.call_args_list:
environment = call.kwargs["env_overrides"]
self.assertEqual(environment["PROTONPATH"], "GE-Proton")
self.assertEqual(environment["PROTON_USE_WOW64"], "1")
self.assertEqual(environment["WINEPREFIX"], str(self.paths.ge_proton_prefix))
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.umu_prefix)
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.proton_prefix)
def test_failed_directx_postcondition_does_not_mark_runtime_ready(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "DirectX setup completed without"):
prepare_game_runtime(self.paths, self.selection, self.runner)
self.assertFalse(self.paths.game_runtime_marker.exists())
self.assertFalse(game_runtime_ready(self.paths, self.selection))
def test_shared_clients_cannot_reuse_a_prefix_for_another_runtime(self) -> None:
self.paths.state.mkdir(parents=True)
for prefix in (self.paths.umu_prefix, self.paths.ge_proton_prefix):
directory = prefix / "drive_c/windows/syswow64"
directory.mkdir(parents=True)
for name in ("d3dx9_43", "d3dcompiler_43"):
(directory / f"{name}.dll").touch()
(prefix / "user.reg").write_text('"*d3dx9_43"="native"\n"*d3dcompiler_43"="native"\n')
self.paths.game_runtime_marker.write_text(json.dumps({
"configuration_version": 1, "runtime": asdict(self.selection),
}))
self.assertTrue(game_runtime_ready(self.paths, self.selection))
self.assertFalse(game_runtime_ready(self.paths, RuntimeSelection("umu", "/usr/bin/umu-run", None, None)))
def test_shared_ge_clients_keep_same_environment_except_for_verb(self) -> None:
for concurrent in (False, True):
run_game(
self.paths, Settings(), self.selection, self.runner,
use_gamebox=False, concurrent_prefix_launch=concurrent,
)
first, second = [dict(call.kwargs["env_overrides"]) for call in self.runner.run.call_args_list]
self.assertEqual(first.pop("PROTON_VERB"), "waitforexitandrun")
self.assertEqual(second.pop("PROTON_VERB"), "runinprefix")
self.assertEqual(first, second)
def test_renderer_is_environment_and_native_helpers_preserve_unrelated_overrides(self) -> None:
for kind in ("umu", "ge-proton", "proton7", "proton7-container"):
for renderer, value, override in (("dxvk", "0", "d3d9=n,b"), ("wined3d", "1", "d3d9=b")):
with self.subTest(kind=kind, renderer=renderer):
selection = RuntimeSelection(kind, "/usr/bin/umu-run", "/Steam/proton", "/Steam")
run_game(
self.paths, Settings(renderer=renderer), selection, self.runner,
use_gamebox=False,
extra_environment={"WINEDLLOVERRIDES": "*d3dx9_43,foo=b;d3dcompiler_43=;d3d9=b"},
)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], value)
self.assertEqual(environment["WINEDLLOVERRIDES"],
f"foo=b;d3dx9_43=n,b;d3dcompiler_43=n,b;{override}")
self.assertNotIn("PROTON_USE_WINED3D", " ".join(self.runner.run.call_args.args[0]))
def test_windows_arguments_are_passed_in_order_without_shell_expansion(self) -> None:
folder = self.paths.runtime_view / "New Client"
folder.mkdir()
(folder / "CLIENT.exe").touch()
arguments = ("NEW_TOKEN", r"C:\data\assets", "", "a b", "$(echo literal)")
option = DepotStartOption("New Realm", "New Client/client.exe", subprocess.list2cmdline(arguments))
run_game(self.paths, Settings(), self.selection, self.runner, start_option=option, use_gamebox=False)
call = self.runner.run.call_args
self.assertEqual(call.args[0], ["/usr/bin/umu-run", str(folder / "CLIENT.exe"), *arguments])
self.assertEqual(call.kwargs["cwd"], self.paths.runtime_view)
def test_missing_advertised_executable_fails_before_runtime_start(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "missing New.exe"):
run_game(self.paths, Settings(), self.selection, self.runner,
start_option=DepotStartOption("New", "New.exe", ""), use_gamebox=False)
self.runner.run.assert_not_called()
def test_build_mismatch_blocks_play_before_game_view_or_runtime_changes(self) -> None:
self.paths.state.mkdir(parents=True)
self.paths.native_depot_state.write_text(json.dumps({"build": 100, "dry_run": False}))
api = MagicMock()
api.current_telemetry.return_value = None
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings()
api.is_running_inside_steam_runtime.return_value = False
for build in (99, 101, None):
api.read_cached_depot_launch_catalog.return_value = (
DepotLaunchCatalog(build, (DepotStartOption("Babel", "Game.exe", "NEW"),), "Babel")
if build is not None else None
)
with self.subTest(build=build), self.assertRaisesRegex(DepotError, "update.*before playing"):
play_locked(api, self.paths, None, False, False, False, False)
api.prepare_runtime_view.assert_not_called()
api.prepare_game_runtime.assert_not_called()
api.run_game.assert_not_called()
def test_proton_telemetry_does_not_report_host_wine_as_bundled_wine(self) -> None:
with patch("ptlinux.telemetry.runtime.shutil.which", return_value="/usr/bin/wine") as which:
for kind in ("umu", "ge-proton"):
self.assertIsNone(_wine_command({"kind": kind, "executable": "/usr/bin/umu-run"}, {}))
which.assert_not_called()
(self.paths.ge_proton_prefix / "drive_c/windows").mkdir(parents=True)
self.assertEqual(_selected_prefix(self.paths, {"runtime": asdict(self.selection)}), self.paths.ge_proton_prefix)
def test_gstreamer_negotiation_errors_are_visible_without_claiming_crash_cause(self) -> None:
report = runtime_issue_summary(
"GStreamer-Video-CRITICAL: gst_video_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
"GStreamer-Audio-CRITICAL: gst_audio_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
)
self.assertEqual(report["gstreamer_caps_error_count"], 2)
self.assertFalse(report["gstreamer_stream_error"])
class GameKeyboardFocusTests(unittest.TestCase):
def test_startup_hands_keyboard_focus_to_the_desktop_game_window(self) -> None:
# Model a WM that rejects raw X11 focus changes to an inactive client.
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
active, keyboard = [7], [7]
pending: list[int] = []
def activate(window: int) -> bool:
pending.append(window)
return True
def settle(_seconds: float) -> None:
if pending:
active[0] = pending.pop()
def focus(window: int) -> None:
if active[0] == window:
keyboard[0] = window
mapper.activate_window.side_effect = activate
mapper.window_is_active.side_effect = lambda window: active[0] == window
mapper.window_is_focused.side_effect = lambda window: keyboard[0] == window
mapper.restore_input_focus.side_effect = focus
with patch.object(game_window.time, "sleep", side_effect=settle):
game_window._focus_game_window(mapper, 12)
self.assertEqual((active[0], keyboard[0]), (12, 12))
def test_activation_preserves_minimize_before_and_during_handoff(self) -> None:
for states, requests in (([3], 0), ([1, 3], 1)):
with self.subTest(states=states):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.side_effect = states
mapper.window_is_active.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12)
self.assertEqual(mapper.activate_window.call_count, requests)
mapper.focus_window.assert_not_called()
def test_nested_gamescope_focus_does_not_request_desktop_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.window_is_focused.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12, activate=False)
mapper.activate_window.assert_not_called()
mapper.restore_input_focus.assert_called_once_with(12)
mapper.focus_window.assert_not_called()
def test_activation_uses_only_the_games_own_user_time(self) -> None:
for properties, expected in (
({}, 0),
({(12, b"_NET_WM_USER_TIME"): (12345,)}, 12345),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(13, b"_NET_WM_USER_TIME"): (45678,)}, 45678),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(12, b"_NET_WM_USER_TIME"): (56789,)}, 56789),
):
with self.subTest(properties=properties):
mapper: game_window._X11 = object.__new__(game_window._X11)
with (
patch.object(mapper, "_intern_atom", return_value=77),
patch.object(
mapper, "_long_property",
side_effect=lambda window, name, _count: properties.get((window, name)),
),
patch.object(mapper, "_send_root_client_message", return_value=True) as send,
):
self.assertTrue(mapper.activate_window(12))
send.assert_called_once_with(12, 77, (1, expected, 0, 0, 0))
def test_focus_diagnostics_distinguish_a_request_from_actual_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.activate_window.return_value = True
mapper.window_is_active.return_value = False
mapper.window_diagnostic_snapshot.return_value = {"active": False, "focused": False}
diagnostics = MagicMock()
with (
patch.object(game_window.time, "sleep"),
patch.object(game_window, "WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS", 0.0),
):
game_window._focus_game_window(mapper, 12, diagnostics=diagnostics)
diagnostics.record.assert_called_once_with(
"focus_handoff", window_id=12, activation_requested=True,
activation_submitted=True, outcome="not_activated", active=False, focused=False,
)
def test_focus_repair_respects_background_and_minimized_windows(self) -> None:
for state, active in ((1, False), (3, True)):
with self.subTest(state=state, active=active):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = state
mapper.window_is_active.return_value = active
mapper.window_is_focused.return_value = False
self.assertFalse(game_window._repair_game_window_focus(mapper, 12))
mapper.restore_input_focus.assert_not_called()
mapper.activate_window.assert_not_called()
mapper.focus_window.assert_not_called()
def test_borderless_launch_keeps_focus_repair_without_live_resizing(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.root_window_ids.return_value = set()
mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
process = MagicMock()
process.poll.return_value = None
with (
patch.object(game_window, "_X11", return_value=mapper),
patch.object(game_window.subprocess, "Popen", return_value=process),
patch.object(game_window, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(game_window, "_prepare_game_window"),
patch.object(game_window, "_focus_game_window"),
patch.object(game_window, "_guard_game_window_startup", return_value=True),
patch.object(game_window, "_maintain_game_window_scaling") as resize,
patch.object(game_window, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(game_window.run_game_window(["unused"], borderless=True), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=game_window.WINDOW_TITLE,
existing_window_ids=set(), expected_pid=mapper.window_pid.return_value,
)
resize.assert_not_called()
mapper.close.assert_called_once_with()
def test_child_input_focus_is_preserved_and_foreign_focus_is_rejected(self) -> None:
for focused, parents, expected in (
(12, {}, True),
(14, {14: 13, 13: 12}, True),
(24, {24: 23, 23: 1000, 1000: 0}, False),
(24, {}, False), # The focused window vanished during the read.
(24, {24: 25, 25: 24}, False),
(0, {}, False),
(1, {}, False),
):
with self.subTest(focused=focused, parents=parents):
mapper: game_window._X11 = object.__new__(game_window._X11)
mapper.display = ctypes.c_void_p(1)
mapper.library = MagicMock()
allocations: list[object] = []
def read_focus(_display, pointer, _revert):
pointer._obj.value = focused
return 1
def query_tree(_display, window, root, parent, children, count):
if window not in parents:
return 0
root._obj.value = 1000
parent._obj.value = parents[window]
allocation = (ctypes.c_ulong * 1)(99)
allocations.append(allocation)
ctypes.cast(children, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))[0] = allocation
count._obj.value = 1
return 1
mapper.library.XGetInputFocus.side_effect = read_focus
mapper.library.XQueryTree.side_effect = query_tree
with (
patch.object(mapper, "window_state", return_value=1),
patch.object(mapper, "window_is_active", return_value=True),
patch.object(mapper, "restore_input_focus") as restore,
):
self.assertEqual(game_window._repair_game_window_focus(mapper, 12), expected)
self.assertEqual(restore.call_count, 0 if expected else 1)
self.assertEqual(mapper.library.XFree.call_count, len(allocations))

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
from contextlib import redirect_stderr, redirect_stdout
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock
from ptlinux.application.commands.play import (
game_exit_metadata,
game_failure_reason,
game_readiness_signal,
play_locked,
print_game_outcome,
runtime_issue_summary,
)
from ptlinux.command import CommandOutcome
from ptlinux.depot import DepotStartOption
from ptlinux.paths import AppPaths
from ptlinux.runtime import RuntimeSelection
from ptlinux.settings import Settings
def missing_status_message(status: int) -> str:
return (
f"Gamescope exited without a recorded game status (compositor status {status}); "
"the game's exit result is unknown.\n"
)
class GamescopeSessionOutcomeTests(unittest.TestCase):
def test_missing_status_is_not_attributed_to_the_game_or_umu(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = None
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
api._runtime_issue_summary.side_effect = runtime_issue_summary
api._game_exit_metadata.side_effect = game_exit_metadata
for ready in (True, False):
for compositor_status, wrapper_status in ((0, 1), (-11, 139), (247, 247), (0, 0)):
with self.subTest(ready=ready, compositor=compositor_status, wrapper=wrapper_status):
api.current_telemetry.return_value.reset_mock()
api._game_readiness_signal.return_value = "window_ready" if ready else None
api.run_game.return_value = CommandOutcome(
[], wrapper_status, "", missing_status_message(compositor_status), 120.0, False, False,
)
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
report = api._emit.call_args.args[0]
self.assertFalse(report["ok"])
self.assertEqual(report["launch_succeeded"], ready)
event_name = "game_exited" if ready else "game_launch_failed"
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == event_name)
metadata = event.kwargs["metadata"]
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
self.assertFalse(metadata["game_exit_status_available"])
self.assertEqual(metadata["exit_status_source"], "gamescope_supervisor")
self.assertEqual(metadata["gamescope_exit_code"], compositor_status)
self.assertEqual(metadata["exit_code"], wrapper_status)
self.assertNotIn("signal_inferred", metadata)
self.assertNotIn("signal", metadata)
self.assertIn("before the game reported its exit status", event.kwargs["message"])
self.assertNotIn("Game client exited with status", event.kwargs["message"])
def test_truncated_output_retains_observed_shutdown_status(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = None
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
api._runtime_issue_summary.side_effect = runtime_issue_summary
api._game_readiness_signal.side_effect = game_readiness_signal
api._gamescope_presentation_failure.return_value = None
api.parse_window_diagnostic.return_value = None
api._game_exit_metadata.side_effect = game_exit_metadata
def run(*args, **kwargs):
observe = kwargs["output_line_filter"]
observe("Game window ready: PristonTale EU\n")
observe(missing_status_message(247))
return CommandOutcome([], 247, "", "<truncated>final child cleanup output\n", 120.0, False, False)
api.run_game.side_effect = run
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
metadata = event.kwargs["metadata"]
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
self.assertEqual(metadata["gamescope_exit_code"], 247)
self.assertNotIn("signal_inferred", metadata)
self.assertTrue(metadata["game_window_ready"])
def test_unobserved_window_is_not_readiness_evidence(self) -> None:
for message in (
"Game window ready: unmanaged PristonTale EU\n",
"Game window monitoring unavailable; process started: PristonTale EU\n",
):
with self.subTest(message=message):
self.assertIsNone(game_readiness_signal(message))
self.assertFalse(runtime_issue_summary(message)["game_window_ready"])
self.assertEqual(game_readiness_signal(message + "Game window ready: PristonTale EU\n"), "window_ready")
def test_human_output_distinguishes_unknown_status_in_old_and_new_logs(self) -> None:
messages = (
missing_status_message(0),
missing_status_message(-11),
"Gamescope exited without a recorded game status; the game session was interrupted.\n",
)
for message in messages:
with self.subTest(message=message):
outcome = CommandOutcome([], 1, "", message, 120.0, False, False)
issues = runtime_issue_summary(message)
output = io.StringIO()
with redirect_stdout(output), redirect_stderr(output):
print_game_outcome(outcome, None, issues)
self.assertIn("before the game reported its exit status", output.getvalue())
self.assertNotIn("Game client exited", output.getvalue())
self.assertNotIn("crashed", output.getvalue())
def test_missing_status_preserves_stronger_failure_evidence(self) -> None:
cases = (
({"runtime_out_of_memory": True}, None, False, "direct3d_resource_exhaustion"),
(None, {"path": "/tmp/crash_game.dat"}, False, "client_crash_dump"),
(None, None, True, "session_timeout"),
)
for native_log, crash, timed_out, reason in cases:
with self.subTest(reason=reason):
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
self.assertEqual(game_failure_reason(outcome, native_log, crash, {}), reason)
def test_human_output_keeps_crash_and_timeout_evidence(self) -> None:
for timed_out, crash, expected in (
(False, {"path": "/tmp/crash_game.dat"}, "crashed and wrote a dump"),
(True, None, "timed out and was stopped"),
):
with self.subTest(expected=expected):
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
output = io.StringIO()
with redirect_stderr(output):
print_game_outcome(outcome, None, runtime_issue_summary(outcome.stderr), crash)
self.assertIn(expected, output.getvalue())
self.assertNotIn("before the game reported", output.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,110 @@
"""Exercise supervisor failure paths without launching a game or display server."""
from contextlib import redirect_stderr
import io
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import textwrap
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.presentation import gamescope
from scripts.repository_paths import SOURCE_ROOT
class GamescopeShutdownRegressions(unittest.TestCase):
def test_missing_status_storage_cannot_be_reported_as_a_clean_game_exit(self) -> None:
process = MagicMock()
process.poll.return_value = 0
output = io.StringIO()
with (
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
patch.object(gamescope, "_open_host_x11", return_value=None),
patch.object(gamescope, "_create_child_status_file", side_effect=OSError("no space left")),
patch.object(gamescope.subprocess, "Popen", return_value=process) as popen,
redirect_stderr(output),
):
self.assertEqual(gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960), 1)
self.assertNotIn("--keep-alive", popen.call_args.args[0])
self.assertIn("supervision is unavailable", output.getvalue())
self.assertIn("without a recorded game status (compositor status 0)", output.getvalue())
def test_interrupt_during_startup_tuning_closes_the_compositor_and_state_files(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
mapper = MagicMock()
process = MagicMock()
process.poll.return_value = None
process.wait.return_value = -15
with (
patch.dict(os.environ, {"DISPLAY": ":0", "XDG_RUNTIME_DIR": temporary}, clear=True),
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
patch.object(gamescope, "_open_host_x11", return_value=mapper),
patch.object(gamescope, "_tune_process_window", side_effect=KeyboardInterrupt),
patch.object(gamescope.subprocess, "Popen", return_value=process),
self.assertRaises(KeyboardInterrupt),
):
gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960)
process.terminate.assert_called_once_with()
process.wait.assert_called_once_with(timeout=gamescope.GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS)
mapper.close.assert_called_once_with()
self.assertEqual(list(Path(temporary).iterdir()), [])
def test_child_report_failure_does_not_leave_an_empty_compositor_alive(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake_gamescope = root / "gamescope"
fake_gamescope.write_text(
f"#!{sys.executable}\n" + textwrap.dedent("""\
import subprocess, sys, time
separator = sys.argv.index('--')
subprocess.run(sys.argv[separator + 1:], check=False)
# Gamescope's default exits when the primary child finishes;
# --keep-alive disables that behavior, even if the child failed.
if '--keep-alive' in sys.argv[1:separator]:
time.sleep(15)
"""), encoding="utf-8",
)
fake_gamescope.chmod(0o700)
runner = root / "supervisor.py"
runner.write_text(textwrap.dedent("""\
from pathlib import Path
import sys
from unittest.mock import patch
from ptlinux.presentation import gamescope
status = Path(sys.argv[2])
with (
patch.object(gamescope, '_select_host_backend', return_value='wayland'),
patch.object(gamescope, '_select_wayland_gamescope', return_value=(sys.argv[1], True, False)),
patch.object(gamescope, '_create_child_status_file', return_value=status),
):
raise SystemExit(gamescope.run_gamescope(
[sys.argv[1], '--', sys.executable, '-c', 'raise SystemExit(7)'], 1280, 960,
))
"""), encoding="utf-8")
for status, expected in ((root / "status", 7), (root / "removed-directory" / "status", 1)):
with self.subTest(status=status):
process = subprocess.Popen(
[sys.executable, str(runner), str(fake_gamescope), str(status)],
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT)},
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
)
try:
_stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.communicate(timeout=5)
raise
self.assertEqual(process.returncode, expected, stderr)
if expected == 1:
self.assertIn("Could not report the nested game status", stderr)
self.assertIn("without a recorded game status", stderr)
else:
self.assertNotIn("without a recorded game status", stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,690 @@
from __future__ import annotations
from contextlib import ExitStack, redirect_stderr, redirect_stdout
from dataclasses import replace
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import io
import json
import os
from pathlib import Path
import resource
import runpy
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from scripts.repository_paths import SOURCE_ROOT
from ptlinux.command import CommandOutcome, CommandRunner
from ptlinux.application.commands.play import (
game_exit_metadata,
game_failure_reason,
play_locked,
print_game_outcome,
recommended_runtime_comparison,
runtime_issue_summary,
)
from ptlinux.depot import DepotClient, DepotError, DepotFile, DepotStartOption
from ptlinux.diagnostics.native_logs import parse_native_log_lines
from ptlinux.doctor import inspect_host
from ptlinux.game.ptr_compat import PtrCompatibilityError, ptr_bcrypt_dll_overrides, stage_ptr_bcrypt_compatibility
from ptlinux.game.runtime import RuntimeErrorPT, RuntimeSelection, prepare_game_runtime, run_game, run_runtime_prefix_command
from ptlinux.game.runtime_view import prepare_runtime_view
from ptlinux.gamebox import build_gamebox_command
from ptlinux.gui_bootstrap import recover_gui_python, tkinter_path
from ptlinux.locking import managed_game_process_is_active
from ptlinux.paths import AppPaths
from ptlinux.presentation.gamescope import _read_gamescope_version, _supervised_command, _wait_for_optimized_gamescope
from ptlinux.settings import Settings
from ptlinux.telemetry.dependencies import _dependency_health
from ptlinux.telemetry.runtime import _selected_prefix
from ptlinux.telemetry import _resource_usage
class NativeCrashLogTests(unittest.TestCase):
def test_current_client_collision_and_minimap_allocation_failures(self) -> None:
log = r"""
[18:16:32.2832206] [CRIT] - Stage 0x3d1d0020 (not in the map list) has no scene/collision: stage without new stage at X: -10 Z: -110. 0 more lookups in the last minute.
[18:16:38.7429594] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:38.7429983] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:38.7517353] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:38.7517827] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:39.2249996] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:39.2250202] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
[18:16:39.2271872] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
[18:16:39.2271993] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
"""
analysis = parse_native_log_lines(log.splitlines())
summary = analysis.to_summary()
self.assertTrue(summary["stage_without_new_stage"])
self.assertTrue(summary["runtime_out_of_memory"])
self.assertEqual(summary["out_of_memory_count"], 4)
self.assertEqual(
summary["out_of_memory_effect_paths"],
[r"game\scripts\shaders\UI\Minimap.fx"],
)
self.assertEqual(
len([event for event in analysis.events if event.kind == "stage_without_new_stage"]),
1,
)
def test_collision_warning_alone_does_not_imply_resource_exhaustion(self) -> None:
analysis = parse_native_log_lines([
"[21:14:40.2199865] [CRIT] - Stage 0x43635c38 (not in the map list) "
"has no scene/collision: stage without new stage at X: -10 Z: -110. "
"0 more lookups in the last minute."
])
self.assertTrue(analysis.stage_without_new_stage)
self.assertFalse(analysis.runtime_out_of_memory)
self.assertFalse(analysis.import_scene_threw)
self.assertFalse(analysis.asset_pack_health_failed)
class ResourceExhaustionGuidanceTests(unittest.TestCase):
def setUp(self) -> None:
self.native_log = {
"runtime_out_of_memory": True,
"out_of_memory_count": 4,
"out_of_memory_effect_paths": [r"game\scripts\shaders\UI\Minimap.fx"],
"stage_without_new_stage": True,
"managed_asset_integrity_failed": False,
}
self.runtime_issues = runtime_issue_summary(
"Game window ready: PristonTale EU V23.86.96\n"
"plugin.so: wrong ELF class: ELFCLASS64\n"
"libavfilter.so.7: cannot open shared object file: No such file or directory\n"
)
def test_proton7_report_keeps_oom_classification_and_suggests_ge(self) -> None:
outcome = CommandOutcome([], 241, "", "", 138.0, False, False)
self.assertEqual(
game_failure_reason(outcome, self.native_log, None, game_exit_metadata(241, "proton7")),
"direct3d_resource_exhaustion",
)
for kind in ("proton7", "proton7-container", "umu"):
with self.subTest(runtime=kind):
self.assertEqual(
recommended_runtime_comparison(
kind, self.runtime_issues, current_game_log=self.native_log, realm="Babel",
),
"ge-proton",
)
def test_oom_evidence_takes_priority_over_secondary_runtime_warnings(self) -> None:
issues = {
**self.runtime_issues,
"gstreamer_stream_error": True,
"dbghelp_symdeferred_assertion_count": 1,
"host_futex_fatal_count": 1,
}
for kind, expected in (("proton7", "ge-proton"), ("proton7-container", "ge-proton"), ("ge-proton", None)):
with self.subTest(runtime=kind):
self.assertEqual(
recommended_runtime_comparison(kind, issues, current_game_log=self.native_log, realm="Babel"),
expected,
)
def test_ptr_and_publisher_realm_alias_keep_the_required_runtime(self) -> None:
for realm in ("PTR Babel", "ptr babel", "Test Babel", "test babel"):
with self.subTest(realm=realm):
self.assertIsNone(recommended_runtime_comparison(
"proton7", self.runtime_issues, current_game_log=self.native_log, realm=realm,
))
def test_plugin_and_collision_warnings_alone_do_not_suggest_a_runtime_switch(self) -> None:
for log in (None, {**self.native_log, "runtime_out_of_memory": False}):
with self.subTest(log=log):
self.assertIsNone(recommended_runtime_comparison(
"proton7", self.runtime_issues, current_game_log=log, realm="Babel",
))
def test_completed_play_reports_the_current_log_recommendation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
paths.runtime_view.mkdir(parents=True)
api = MagicMock()
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings(runtime="proton7")
api.is_running_inside_steam_runtime.return_value = False
api.read_cached_depot_launch_catalog.return_value = None
api.read_current_depot_file_catalog.return_value = None
api._current_game_log.return_value = self.native_log
api._current_game_crash.return_value = None
api.select_runtime.return_value = RuntimeSelection("proton7", "proton", "proton", None)
api.run_game.return_value = CommandOutcome([], 241, "", "", 138.0, False, False)
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
api._game_readiness_signal.return_value = "window_ready"
api._game_exit_metadata.return_value = game_exit_metadata(241, "proton7")
for realm, expected in (("Babel", "ge-proton"), ("PTR Babel", None)):
with self.subTest(realm=realm):
api.current_telemetry.return_value.reset_mock()
api.select_depot_start_option.return_value = DepotStartOption(realm, "Game.exe", "PTEU_REBORN")
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
report = api._emit.call_args.args[0]
self.assertFalse(report["ok"])
self.assertTrue(report["degraded"])
self.assertEqual(report["runtime_issues"].get("recommended_one_variable_comparison"), expected)
exit_event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
self.assertEqual(exit_event.kwargs["metadata"]["failure_reason"], "direct3d_resource_exhaustion")
self.assertEqual(exit_event.kwargs["metadata"]["runtime_issues"].get("recommended_one_variable_comparison"), expected)
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
def test_runtime_advice_is_visible_in_the_launcher_output(self) -> None:
output = io.StringIO()
with redirect_stderr(output):
print_game_outcome(
CommandOutcome([], 241, "", "", 138.0, False, False), None,
{**self.runtime_issues, "recommended_one_variable_comparison": "ge-proton"},
)
self.assertIn("runtime comparison: ge-proton", output.getvalue())
class ResourceUsageScopeTests(unittest.TestCase):
def test_peak_memory_is_explicitly_launcher_rss_and_cpu_is_launcher_time(self) -> None:
with (
patch("ptlinux.telemetry.sys.platform", "linux"),
patch("ptlinux.telemetry.resource.getrusage", return_value=SimpleNamespace(
ru_maxrss=305398, ru_utime=40.0, ru_stime=0.891,
)) as read_usage,
):
self.assertEqual(_resource_usage(), {
"resource_usage_scope": "launcher_process",
"peak_memory_mb": 298.24,
"cpu_time_seconds": 40.891,
})
read_usage.assert_called_once_with(resource.RUSAGE_SELF)
def test_unavailable_process_usage_does_not_emit_a_scope_without_measurements(self) -> None:
with patch("ptlinux.telemetry.resource.getrusage", side_effect=OSError("unavailable")):
self.assertEqual(_resource_usage(), {})
class GuiRecoveryTests(unittest.TestCase):
def test_missing_python_package_is_not_hidden_by_a_present_extension(self) -> None:
def imports(name: str) -> object:
if name == "tkinter":
raise ModuleNotFoundError("No module named 'tkinter'")
return MagicMock()
with patch("ptlinux.gui_bootstrap.import_module", side_effect=imports):
self.assertIsNone(tkinter_path())
def test_broken_tcl_data_is_reported_as_missing_gui_support(self) -> None:
class TclError(Exception):
pass
tk = MagicMock()
tk.Tcl.side_effect = TclError("Can't find a usable init.tcl")
with patch("ptlinux.gui_bootstrap.import_module", return_value=tk):
self.assertIsNone(tkinter_path())
def test_fallback_restarts_the_same_release_and_arguments_with_system_python(self) -> None:
with (
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
patch("ptlinux.gui_bootstrap.Path.is_file", return_value=True),
patch.object(sys, "executable", "/custom/python3"),
patch.dict(os.environ, {"PYTHONHOME": "/custom", "PYTHONPATH": "/old", "DISPLAY": ":0"}, clear=True),
patch("ptlinux.gui_bootstrap.subprocess.run", return_value=SimpleNamespace(returncode=0)) as probe,
patch("ptlinux.gui_bootstrap.os.execve", side_effect=SystemExit(0)) as restart,
self.assertRaises(SystemExit),
):
recover_gui_python(["gui"])
self.assertEqual(probe.call_args.args[0][0], "/usr/bin/python3")
self.assertEqual(restart.call_args.args[1], ["/usr/bin/python3", "-B", "-P", "-m", "ptlinux", "gui"])
env = restart.call_args.args[2]
self.assertNotIn("PYTHONHOME", env)
self.assertEqual(env["DISPLAY"], ":0")
self.assertTrue((Path(env["PYTHONPATH"]) / "ptlinux/__main__.py").is_file())
def test_explicit_interpreter_and_retry_guard_do_not_loop(self) -> None:
for env in ({"PTLINUX_PYTHON": "/custom/python"}, {"PTLINUX_GUI_PYTHON_RETRIED": "1"}):
with (
patch.dict(os.environ, env, clear=True),
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
patch("ptlinux.gui_bootstrap.subprocess.run") as probe,
):
self.assertFalse(recover_gui_python([]))
probe.assert_not_called()
def test_missing_tk_reports_dependency_and_shows_desktop_repair_before_bootstrap(self) -> None:
telemetry = MagicMock()
with (
patch.object(sys, "argv", ["pristontale-eu", "gui"]),
patch("ptlinux.gui_bootstrap.recover_gui_python", return_value=False),
patch("ptlinux.gui_bootstrap.show_gui_dependency_error") as show,
patch("ptlinux.telemetry.initialize_shared_telemetry", return_value=(telemetry, True)),
patch("ptlinux.telemetry.shutdown_shared_telemetry") as shutdown,
patch("ptlinux.umu.provision_umu_for_startup") as bootstrap,
patch("sys.stderr", new_callable=io.StringIO),
self.assertRaises(SystemExit) as stopped,
):
runpy.run_module("ptlinux.__main__", run_name="__main__")
self.assertEqual(stopped.exception.code, 1)
self.assertEqual(telemetry.send_event.call_args.args[0], "dependency_check_failed")
self.assertEqual(telemetry.send_event.call_args.kwargs["metadata"]["dependencies"], ["python-tk"])
telemetry.report_exception.assert_not_called()
bootstrap.assert_not_called()
show.assert_called_once()
shutdown.assert_called_once_with(telemetry)
class RuntimeDependencyTests(unittest.TestCase):
def setUp(self) -> None:
self.stack = ExitStack()
self.addCleanup(self.stack.close)
for target, value in (
("_read_os_release", {"ID": "steamos", "VERSION_ID": "3.8"}),
("_python_tk_path", "tkinter"),
("find_wine_tools", None),
("_umu_path", "/usr/bin/umu-run"),
("inspect_container_host", SimpleNamespace(ready=True, bwrap_path="/usr/bin/bwrap", broken_umu_profile_link=False)),
):
self.stack.enter_context(patch(f"ptlinux.doctor.{target}", return_value=value))
self.stack.enter_context(patch("ptlinux.doctor.shutil.which", side_effect=lambda name, **kw: "/usr/bin/openssl" if name == "openssl" else None))
self.support = {"vulkan_available": True, "vulkan_64bit": True, "vulkan_32bit": True}
def test_steamos_umu_does_not_require_host_wine_or_installation_tools(self) -> None:
for kind in ("umu", "ge-proton"):
with self.subTest(kind=kind):
health = _dependency_health({}, None, {**self.support, "kind": kind})
self.assertTrue(health["healthy"])
self.assertEqual(health["missing_required"], [])
self.assertFalse(health["tools"]["wine"])
self.assertFalse(health["archive_tools_present"])
def test_legacy_prefix_still_requires_tools_until_directx_is_prepared(self) -> None:
for ready in (False, True):
with patch("ptlinux.doctor.native_d3d_ready", return_value=ready):
report = inspect_host({}, vulkan_support=self.support,
game_runtime={"kind": "proton7"}, runtime_prefix=Path("/test/prefix"))
self.assertEqual(report.ok, ready)
missing = {tool.name for tool in report.tools if tool.required and tool.path is None}
self.assertEqual(missing, set() if ready else {"winetricks", "cabextract"})
def test_modern_wow64_uses_64bit_driver_while_umu_keeps_multilib_check(self) -> None:
for kind, expected in (("ge-proton", True), ("umu", False)):
health = _dependency_health({}, None, {**self.support, "kind": kind, "vulkan_32bit": False})
self.assertEqual(health["healthy"], expected)
class LaunchIsolationTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.runtime_view.mkdir(parents=True)
(self.paths.runtime_view / "Game.exe").touch()
self.selection = RuntimeSelection("ge-proton", "/fake/umu-run", None, None)
self.runner = MagicMock(dry_run=False)
self.host = patch("ptlinux.game.runtime._require_container_host")
self.host.start()
self.addCleanup(self.host.stop)
def _bundled_proton_launcher(self, kind: str) -> RuntimeSelection:
proton_directory = self.root / "bundled Proton"
proton_directory.mkdir(exist_ok=True)
(proton_directory / "ptlinux_proton_test_dependency.py").write_text("VALUE = 'bundled'\n")
proton = proton_directory / "proton"
proton.write_text(
"import json, sys\n"
"from ptlinux_proton_test_dependency import VALUE\n"
"print(json.dumps({'dependency': VALUE, 'safe_path': sys.flags.safe_path, "
"'argv': sys.argv[1:]}))\n"
)
umu = self.root / "umu-run"
umu.write_text(
f"#!{sys.executable}\n"
"import os, sys\n"
f"os.execv(sys.executable, [sys.executable, {str(proton)!r}, *sys.argv[1:]])\n"
)
umu.chmod(0o755)
return RuntimeSelection(kind, str(umu), str(proton), str(self.root / "Steam"))
def test_bundled_proton_imports_survive_launcher_safe_path_during_setup(self) -> None:
for kind in ("umu", "ge-proton"):
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
selection = self._bundled_proton_launcher(kind)
runner = CommandRunner(self.root / "commands.jsonl")
with patch("ptlinux.game.runtime.native_d3d_ready", side_effect=[False, True]):
outcomes = prepare_game_runtime(self.paths, selection, runner)
child = json.loads(outcomes[0].stdout)
self.assertEqual(child["dependency"], "bundled")
self.assertFalse(child["safe_path"])
self.assertEqual(child["argv"], ["winetricks", "-q", "d3dx9_43", "d3dcompiler_43"])
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
def test_bundled_proton_imports_survive_safe_path_for_games_and_helpers(self) -> None:
for kind in ("umu", "ge-proton", "proton7-container"):
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
selection = self._bundled_proton_launcher(kind)
runner = CommandRunner(self.root / "commands.jsonl")
helper = run_runtime_prefix_command(self.paths, selection, runner,
["reg.exe", "query"], timeout_seconds=5)
with redirect_stdout(io.StringIO()):
game = run_game(self.paths, Settings(), selection, runner,
use_gamebox=False, timeout_seconds=5)
for outcome in (helper, game):
child = json.loads(outcome.stdout)
self.assertEqual(child["dependency"], "bundled")
self.assertFalse(child["safe_path"])
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
def test_launcher_wrappers_ignore_python_modules_in_the_game_directory(self) -> None:
shadow = self.paths.runtime_view / "ptlinux"
shadow.mkdir()
(shadow / "__init__.py").write_text("raise RuntimeError('game directory shadowed launcher')\n")
command = build_gamebox_command(["umu-run", "Game.exe"],
Settings(allow_window_resizing=True), game_size=(1280, 960), gamescope_path="gamescope")
separator = command.index("--")
command[separator + 1:] = _supervised_command(
command[separator + 1:], self.root / "status", "wayland")
environment = {
**os.environ,
"PYTHONSAFEPATH": "",
"PYTHONPATH": str(SOURCE_ROOT),
}
for module in ("ptlinux.gamescope_runtime", "ptlinux.gamescope_child_runtime", "ptlinux.gamebox_runtime"):
with self.subTest(module=module):
index = command.index(module)
wrapper = command[command.index(sys.executable, index - 3):index + 1]
result = subprocess.run([*wrapper, "--help"], cwd=self.paths.runtime_view,
env=environment, capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("usage:", result.stdout)
def test_gamescope_can_finish_its_shutdown_without_sigkill(self) -> None:
cleanup = self.root / "display-closed"
status = self.root / "status"
status.write_text("0\n")
process = subprocess.Popen(
[sys.executable, "-c",
"import signal\nfrom pathlib import Path\n"
"def stop(*args):\n"
f" Path({str(cleanup)!r}).touch()\n"
" raise SystemExit(0)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"print('ready', flush=True)\n"
"signal.pause()\n"],
stdout=subprocess.PIPE, text=True,
)
try:
assert process.stdout is not None
self.assertEqual(process.stdout.readline().strip(), "ready")
self.assertEqual(_wait_for_optimized_gamescope(process, status), 0)
self.assertEqual(process.returncode, 0)
self.assertTrue(cleanup.exists())
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
if process.stdout is not None:
process.stdout.close()
def test_version_detection_recognizes_the_bundled_gamescope_filename(self) -> None:
banner = (
"[gamescope-3.16.20-ubuntu26.04-amd64] [\x1b[0;34mInfo\x1b[0m] "
"\x1b[0;37mconsole:\x1b[0m gamescope-3.16.20-ubuntu26.04-amd64 "
"version 3.16.20+ds-1 (gcc 15.2.0)\n"
)
with patch("ptlinux.presentation.gamescope.subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout=banner)):
self.assertEqual(_read_gamescope_version("gamescope-custom"), (3, 16, 20))
def test_gamescope_cleanup_timeout_preserves_the_game_failure(self) -> None:
status = self.root / "status"
status.write_text("-11\n")
process = MagicMock()
process.poll.return_value = None
process.wait.side_effect = [subprocess.TimeoutExpired("gamescope", 2), -9]
output = io.StringIO()
with patch("sys.stderr", output):
self.assertEqual(_wait_for_optimized_gamescope(process, status), 139)
process.terminate.assert_called_once_with()
process.kill.assert_called_once_with()
self.assertTrue(runtime_issue_summary(output.getvalue())["gamescope_forced_cleanup"])
def test_gamescope_exit_without_game_status_is_not_reported_as_clean(self) -> None:
status = self.root / "status"
for contents in (None, "", "invalid", "999"):
for compositor_status, expected in ((0, 1), (-11, 139), (247, 247)):
with self.subTest(contents=contents, status=compositor_status):
status.unlink(missing_ok=True)
if contents is not None:
status.write_text(contents)
process = MagicMock()
process.poll.return_value = compositor_status
output = io.StringIO()
with patch("sys.stderr", output):
self.assertEqual(_wait_for_optimized_gamescope(process, status), expected)
issues = runtime_issue_summary(output.getvalue())
self.assertTrue(issues["gamescope_child_status_missing"])
self.assertEqual(issues["gamescope_exit_code"], compositor_status)
process.terminate.assert_not_called()
def test_optional_runtime_warnings_are_distinct_from_presentation_failures(self) -> None:
output = (
"sd_bus_call(g_dbus, message, UINT64_MAX, NULL, &reply)failed with ret -30\n" * 2
+ "No CAP_SYS_NICE, falling back to regular-priority compute and threads.\n"
+ "Proton: Error: unable to use parent for game drive, path \n"
+ "Game window ready: PristonTale EU V23.86.96\n"
)
report = runtime_issue_summary(output)
self.assertEqual(report["gamescope_memory_protection_warning_count"], 2)
self.assertTrue(report["gamescope_priority_fallback"])
self.assertTrue(report["proton_game_drive_warning"])
self.assertTrue(report["game_window_ready"])
self.assertFalse(report["gamescope_child_status_missing"])
self.assertFalse(report["gamescope_forced_cleanup"])
self.assertEqual(report["gamescope_vulkan_output_failed_count"], 0)
def test_actual_child_receives_literal_arguments_prefix_and_renderer(self) -> None:
fake_umu = self.root / "fake umu-run"
fake_umu.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\n"
"print(json.dumps({'argv': sys.argv[1:], 'cwd': os.getcwd(), "
"'prefix': os.environ['WINEPREFIX'], 'renderer': os.environ.get('PROTON_USE_WINED3D'), "
"'verb': os.environ['PROTON_VERB']}))\n"
)
fake_umu.chmod(0o755)
selection = replace(self.selection, executable=str(fake_umu))
arguments = ("NEW_TOKEN", "", r"C:\game data\assets", "$(touch should-not-exist)")
option = DepotStartOption("Babel", "Game.exe", subprocess.list2cmdline(arguments))
runner = CommandRunner(self.root / "commands.jsonl")
with redirect_stdout(io.StringIO()):
outcome = run_game(self.paths, Settings(renderer="wined3d"), selection, runner,
start_option=option, use_gamebox=False, timeout_seconds=5)
child = json.loads(outcome.stdout)
self.assertEqual(child["argv"], [str(self.paths.runtime_view / "Game.exe"), *arguments])
self.assertEqual(child["cwd"], str(self.paths.runtime_view))
self.assertEqual(child["prefix"], str(self.paths.ge_proton_prefix))
self.assertEqual(child["renderer"], "1")
self.assertEqual(child["verb"], "waitforexitandrun")
self.assertFalse((self.paths.runtime_view / "should-not-exist").exists())
def test_changed_renderer_or_sync_cannot_join_an_active_prefix(self) -> None:
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
marker = (self.paths.state / "game-launch.json").read_bytes()
for settings in (Settings(renderer="wined3d"), Settings(proton_sync="esync")):
self.runner.run.reset_mock()
shared = MagicMock()
with self.assertRaisesRegex(RuntimeErrorPT, "close every client"):
run_game(self.paths, settings, self.selection, self.runner,
use_gamebox=False, concurrent_prefix_launch=True, before_start=shared)
self.runner.run.assert_not_called()
shared.assert_not_called()
self.assertEqual((self.paths.state / "game-launch.json").read_bytes(), marker)
def test_preparation_finishes_before_shared_lease_and_spawn(self) -> None:
order = []
def share() -> None:
self.assertTrue((self.paths.state / "game-launch.json").is_file())
order.append("shared")
self.runner.run.side_effect = lambda *a, **kw: order.append("spawn")
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False, before_start=share)
self.assertEqual(order, ["shared", "spawn"])
def test_helpers_join_umu_prefix_without_waiting_for_game_exit(self) -> None:
for kind in ("umu", "ge-proton", "proton7-container"):
selection = RuntimeSelection(kind, "/fake/umu-run", "/Steam/proton", "/Steam")
run_runtime_prefix_command(self.paths, selection, self.runner, ["reg.exe", "query"])
self.assertEqual(self.runner.run.call_args.kwargs["env_overrides"]["PROTON_VERB"], "runinprefix")
def test_missing_selected_prefix_does_not_report_another_prefixs_components(self) -> None:
(self.paths.proton_prefix / "drive_c/windows").mkdir(parents=True)
self.assertIsNone(_selected_prefix(self.paths, {"runtime": {"kind": "ge-proton"}}))
def test_windows_paths_and_long_game_names_are_detected_but_services_are_not(self) -> None:
process = self.root / "proc/1000"
process.mkdir(parents=True)
(process / "environ").write_bytes(f"WINEPREFIX={self.paths.proton_prefix}\0".encode())
(process / "comm").write_text("FutureGameClien\n")
for command, expected in (
(r"C:\Program Files (x86)\Pristontale EU\FutureGameClient.exe", True),
(str(self.paths.runtime_view / "FutureGameClient.exe"), True),
(r"C:\windows\system32\services.exe", False),
(str(self.paths.runtime_view) + "-other/Game.exe", False),
):
(process / "cmdline").write_bytes(command.encode() + b"\0")
self.assertEqual(managed_game_process_is_active(self.paths.runtime_view,
(self.paths.proton_prefix,), process_root=self.root / "proc"), expected)
def test_ptr_dlls_are_removed_before_preparing_a_new_stable_session(self) -> None:
source = self.paths.source_game
for name in ("Game.exe", "game.dll", "game.ini", "game/maps/WhisperingVale.rovug"):
path = source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"future publisher data")
prepare_runtime_view(self.paths, source)
for name in ("bcrypt.dll", "bcrypt_orig.dll"):
(self.paths.runtime_view / name).write_bytes(b"PTR specific DLL")
prepare_runtime_view(self.paths, source)
self.assertFalse((self.paths.runtime_view / "bcrypt.dll").exists())
self.assertFalse((self.paths.runtime_view / "bcrypt_orig.dll").exists())
self.assertEqual((source / "game.dll").read_bytes(), b"future publisher data")
def test_shared_ptr_preparation_checks_bytes_without_replacing_them(self) -> None:
proton = self.root / "proton/proton"
original = proton.parent / "dist/lib/wine/i386-windows/bcrypt.dll"
original.parent.mkdir(parents=True)
proton.touch()
pe = bytearray(128)
pe[:2] = b"MZ"
pe[60:64] = (64).to_bytes(4, "little")
pe[64:68] = b"PE\0\0"
pe[68:70] = (0x14c).to_bytes(2, "little")
pe[88:90] = (0x10b).to_bytes(2, "little")
original.write_bytes(pe)
proxy, target = stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton)
before = (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns)
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
self.assertEqual(before, (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns))
target.write_bytes(b"wrong version")
with self.assertRaises(PtrCompatibilityError):
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
self.assertEqual(target.read_bytes(), b"wrong version")
self.assertEqual(ptr_bcrypt_dll_overrides("*bcrypt,foo=b"), "foo=b;bcrypt=n")
class Response:
def __init__(self, body: bytes) -> None:
self.body = io.BytesIO(body)
self.headers = {"Content-Length": str(len(body))}
self.status = 200
def read(self, size: int = -1) -> bytes:
return self.body.read(size)
def close(self) -> None:
self.body.close()
class DepotRecoveryTests(unittest.TestCase):
def test_manifest_read_timeout_retries_and_closes_failed_response(self) -> None:
failed = MagicMock(headers={})
failed.read.side_effect = TimeoutError("The read operation timed out")
opener = MagicMock(side_effect=[failed, Response(b'{"Build": 42}')])
client = DepotClient(opener=opener, retry_delays=(0,), sleeper=lambda _: None)
self.assertEqual(client._get_json("https://cdn.example/manifest.json"), {"Build": 42})
failed.close.assert_called_once()
self.assertEqual(opener.call_count, 2)
def test_permanent_manifest_error_falls_back_without_repeated_requests(self) -> None:
opener = MagicMock(side_effect=HTTPError("https://cdn.example/manifest.json", 404, "missing", {}, None))
client = DepotClient(opener=opener, retry_delays=(0, 0), sleeper=lambda _: None)
with self.assertRaises(DepotError):
client._get_json("https://cdn.example/manifest.json")
self.assertEqual(opener.call_count, 1)
def test_timeout_cooldown_avoids_failed_mirror_on_following_files_then_expires(self) -> None:
body = b"verified data"
file = DepotFile("file.bin", 42, len(body), hashlib.sha1(body).hexdigest())
urls = []
def open_response(request: Request, *, timeout: float) -> Response:
urls.append(request.full_url)
if len(urls) == 1:
raise TimeoutError("The read operation timed out")
return Response(body)
client = DepotClient(opener=open_response, retry_delays=(0,), sleeper=lambda _: None)
client._candidate_servers = ("primary.example", "secondary.example")
with tempfile.TemporaryDirectory() as temporary, patch("ptlinux.depot.time.monotonic", return_value=100) as clock:
for name in ("first", "second"):
client.download_file("primary.example", file, Path(temporary) / name)
clock.return_value = 161
client.download_file("primary.example", file, Path(temporary) / "third")
self.assertEqual([url.split("/")[2] for url in urls],
["primary.example", "secondary.example", "secondary.example", "primary.example"])
def test_real_http_read_timeout_retains_small_chunk_and_resumes_verified_bytes(self) -> None:
body = b"a small initial block followed by the remaining verified data"
cut = 12
requests = []
recovered = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def do_GET(self) -> None:
requests.append(self.headers.get("Range"))
if len(requests) == 1:
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body[:cut])
self.wfile.flush()
recovered.wait(5)
else:
self.send_response(206)
self.send_header("Content-Length", str(len(body) - cut))
self.send_header("Content-Range", f"bytes {cut}-{len(body)-1}/{len(body)}")
self.end_headers()
self.wfile.write(body[cut:])
recovered.set()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
def local_open(request: Request, *, timeout: float):
return urlopen(Request(f"http://127.0.0.1:{server.server_port}/data",
headers=dict(request.header_items())), timeout=timeout)
client = DepotClient(opener=local_open, timeout_seconds=0.2, retry_delays=(0,), sleeper=lambda _: None)
file = DepotFile("wind_a.TGA", 42, len(body), hashlib.sha1(body).hexdigest())
with tempfile.TemporaryDirectory() as temporary:
destination = Path(temporary) / "wind_a.TGA"
destination.write_bytes(b"previous verified version")
client.download_file("cdn.example", file, destination)
self.assertEqual(destination.read_bytes(), body)
self.assertEqual(requests, [None, f"bytes={cut}-"])
finally:
recovered.set()
server.shutdown()
server.server_close()
thread.join(5)
if __name__ == "__main__":
unittest.main()

1425
tests/test_ptlinux_cli.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,830 @@
import contextlib
import hashlib
import io
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
from unittest import mock
# noinspection PyProtectedMember
from ptlinux.gamescope_runtime import (
_HostFocusPublisher,
_bundled_gamescope_is_usable,
_is_cinnamon_session,
_is_kde_session,
_process_exit_status,
_read_gamescope_version,
_select_host_backend,
_select_wayland_gamescope,
_supervised_command,
_tune_process_window,
_wait_for_optimized_gamescope,
main,
resize_increments,
run_gamescope,
)
from ptlinux.gamebox_runtime import GAMESCOPE_HOST_FOCUS_STATE_ENV
from ptlinux.window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticRecorder,
parse_window_diagnostic,
)
class GamescopeRuntimeTests(unittest.TestCase):
def test_uses_five_percent_resize_steps_with_sensible_bounds(self) -> None:
self.assertEqual(resize_increments(1280, 960), (64, 48))
self.assertEqual(resize_increments(100, 100), (8, 8))
self.assertEqual(resize_increments(7680, 4320), (96, 96))
def test_rejects_invalid_canvas_dimensions(self) -> None:
with self.assertRaisesRegex(ValueError, "positive"):
resize_increments(0, 960)
def test_inserts_selected_backend_before_existing_gamescope_options(self) -> None:
self.assertEqual(
_supervised_command(
["/usr/bin/gamescope", "-w", "1280", "--", "game"],
Path("/tmp/status"),
"wayland",
),
[
"/usr/bin/gamescope",
"--backend",
"wayland",
"-w",
"1280",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
"/tmp/status",
"--",
"game",
],
)
def test_can_force_composition_for_an_unsafe_wayland_build(self) -> None:
command = _supervised_command(
["gamescope", "--", "game"],
Path("/tmp/status"),
"wayland",
force_composition=True,
)
self.assertEqual(command[:4], [
"gamescope",
"--backend",
"wayland",
"--force-composition",
])
def test_reads_the_gamescope_release_tuple(self) -> None:
completed = mock.Mock(
returncode=0,
stdout="[gamescope] gamescope version 3.16.20+ds-1 (gcc 15.2.0)\n"
)
with mock.patch(
"ptlinux.gamescope_runtime.subprocess.run",
return_value=completed,
) as run:
self.assertEqual(
_read_gamescope_version("/usr/games/gamescope"),
(3, 16, 20),
)
run.assert_called_once_with(
["/usr/games/gamescope", "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=1.0,
check=False,
)
def test_selects_upstream_or_launcher_local_wayland_backing_fix(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
side_effect=[(3, 16, 28), (3, 16, 20)],
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=Path("/opt/ptlinux/gamescope"),
):
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/usr/games/gamescope", False, False),
)
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/opt/ptlinux/gamescope", False, True),
)
def test_unsafe_wayland_build_uses_composed_fallback(self) -> None:
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 20),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=False,
),
):
self.assertEqual(
_select_wayland_gamescope("gamescope"),
("gamescope", True, False),
)
def test_kubuntu_keeps_every_native_wayland_build_fully_composed(self) -> None:
environment = {
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
}
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 28),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
) as bundled_gamescope,
):
self.assertEqual(
_select_wayland_gamescope(
"/usr/games/gamescope",
environment=environment,
),
("/usr/games/gamescope", True, False),
)
self.assertTrue(_is_kde_session(environment))
bundled_gamescope.assert_not_called()
def test_recognizes_cinnamon_session_names(self) -> None:
self.assertTrue(
_is_cinnamon_session(
{
"XDG_CURRENT_DESKTOP": "X-Cinnamon:GNOME",
"DESKTOP_SESSION": "cinnamon2d",
}
)
)
self.assertFalse(_is_cinnamon_session({"XDG_CURRENT_DESKTOP": "GNOME"}))
def test_launcher_local_gamescope_matches_its_pinned_digest(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
):
self.assertTrue(_bundled_gamescope_is_usable())
def test_repairs_a_non_executable_verified_launcher_local_gamescope(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
payload = b"verified launcher-local gamescope"
executable.write_bytes(payload)
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
hashlib.sha256(payload).hexdigest(),
),
):
self.assertTrue(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o755)
def test_does_not_make_an_unverified_launcher_local_gamescope_executable(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
executable.write_bytes(b"unexpected contents")
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
"0" * 64,
),
):
self.assertFalse(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o644)
def test_selects_wayland_only_for_an_accessible_socket(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
socket_path = Path(temporary) / "wayland-test"
with socket.socket(socket.AF_UNIX) as wayland_socket:
wayland_socket.bind(str(socket_path))
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": "missing-wayland-socket",
}
),
"sdl",
)
def test_maps_signals_to_a_shell_exit_status(self) -> None:
self.assertEqual(_process_exit_status(-6), 134)
self.assertEqual(_process_exit_status(7), 7)
def test_tunes_resize_steps_and_keeps_the_host_window_composited(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.return_value = True
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock()
process.pid = 1234
process.poll.return_value = None
publisher = mock.Mock()
self.assertEqual(
_tune_process_window(
mapper,
process,
1280,
960,
host_focus_publisher=publisher,
),
(64, 48),
)
self.assertEqual(publisher.update.call_args_list, [
mock.call(mapper, 41),
mock.call(mapper, 41),
])
mapper.enable_window_scaling.assert_called_once_with(41, confirm=False)
mapper.window_allows_scaling.assert_called_once_with(41)
mapper.set_resize_increments.assert_called_once_with(
41,
(1280, 960),
(64, 48),
)
mapper.prefer_composited_presentation.assert_called_once_with(41)
def test_tuning_retains_hints_while_muffin_refreshes_its_frame(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.side_effect = [False, True]
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock(pid=1234)
process.poll.return_value = None
with (
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[0.0, 0.0, 0.1],
),
mock.patch("ptlinux.gamescope_runtime.time.sleep") as sleep,
):
self.assertEqual(
_tune_process_window(mapper, process, 1280, 960),
(64, 48),
)
mapper.find_window_by_pid.assert_called_once_with(1234)
self.assertEqual(
mapper.enable_window_scaling.call_args_list,
[
mock.call(41, confirm=False),
mock.call(41, confirm=False),
],
)
sleep.assert_called_once_with(0.05)
def test_host_focus_publisher_tracks_active_and_minimized_state(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.return_value = 1
mapper.window_is_active.return_value = True
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.window_state.return_value = 3
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
finally:
state_path = publisher.path
publisher.close()
self.assertFalse(state_path.exists())
def test_host_focus_publisher_preserves_an_inactive_minimized_remap(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.3, 10.4],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1, 1]
mapper.window_is_active.side_effect = [True, False, True]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
mapper.minimize_window.assert_called_once_with(41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.minimize_window.assert_called_once_with(41)
finally:
publisher.close()
def test_host_focus_diagnostics_capture_the_minimize_remap_cycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
environment = {
"XDG_RUNTIME_DIR": temporary,
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "b" * 32,
}
output = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.4],
),
contextlib.redirect_stderr(output),
):
diagnostics = WindowDiagnosticRecorder(
"gamescope_host",
environment=environment,
monotonic=lambda: 20.0,
)
publisher = _HostFocusPublisher.create(diagnostics=diagnostics)
summary: dict[str, object] = {}
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1]
mapper.window_is_active.side_effect = [True, False]
mapper.minimize_window.return_value = True
mapper.window_diagnostic_snapshot.return_value = {
"wm_state": "normal",
"allows_resize": True,
"allows_maximize_horizontal": False,
"allows_maximize_vertical": False,
}
mapper.window_net_wm_state_flags.side_effect = [
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": True,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
summary = publisher.summary_details()
finally:
publisher.close()
diagnostics.close(**summary)
payloads = [
parse_window_diagnostic(line)
for line in output.getvalue().splitlines()
]
diagnostics_by_name = {
payload["diagnostic"]: payload
for payload in payloads
if payload is not None
}
self.assertIn("host_minimize_latched", diagnostics_by_name)
self.assertIn("host_hidden_state_cleared", diagnostics_by_name)
remap = diagnostics_by_name["host_inactive_remap"]
details = remap["details"]
assert isinstance(details, dict)
self.assertTrue(details["minimize_request_accepted"])
self.assertFalse(details["allows_maximize_horizontal"])
self.assertEqual(summary["inactive_remaps"], 1)
self.assertEqual(summary["hidden_state_clears"], 1)
self.assertEqual(summary["reiconify_accepted"], 1)
def test_optimized_wait_refreshes_host_focus_before_returning(self) -> None:
process = mock.Mock(pid=1234)
process.poll.return_value = 0
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
publisher = mock.Mock()
self.assertEqual(
_wait_for_optimized_gamescope(
process,
None,
host_focus_publisher=publisher,
host_mapper=mapper,
),
1,
)
publisher.update.assert_called_once_with(mapper, 41)
def test_optimized_wait_returns_the_nested_child_status(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
status_file = Path(temporary) / "status"
status_file.write_text("0\n", encoding="ascii")
process = mock.Mock()
process.poll.return_value = None
self.assertEqual(_wait_for_optimized_gamescope(process, status_file), 0)
process.terminate.assert_called_once_with()
process.kill.assert_not_called()
process.wait.assert_called_once_with(timeout=2.0)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_tunes_the_window_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
mapper = open_host_x11.return_value
process = popen.return_value
process.poll.return_value = 7
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=7,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 7)
self.assertEqual(
popen.call_args.args[0],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertEqual(popen.call_args.kwargs["env"]["SDL_VIDEODRIVER"], "x11")
self.assertEqual(
popen.call_args.kwargs["env"][
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"
],
"0",
)
self.assertIn(
GAMESCOPE_HOST_FOCUS_STATE_ENV,
popen.call_args.kwargs["env"],
)
tune_process_window.assert_called_once_with(
mapper,
process,
1280,
960,
host_focus_publisher=mock.ANY,
)
mapper.close.assert_called_once_with()
self.assertIn("64x48", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11", return_value=None)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_disables_nested_focus_repair_without_host_monitor(
self,
_select_host_backend: mock.Mock,
_open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(io.StringIO()),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
self.assertEqual(
run_gamescope(["gamescope", "--", "game"], 1280, 960),
0,
)
self.assertEqual(
popen.call_args.kwargs["env"][GAMESCOPE_HOST_FOCUS_STATE_ENV],
"unavailable",
)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_wayland_gamescope",
return_value=("/opt/ptlinux/gamescope", False, True),
)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="wayland",
)
def test_wayland_path_avoids_sdl_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
select_wayland_gamescope: mock.Mock,
open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0],
[
"/opt/ptlinux/gamescope",
"--backend",
"wayland",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertNotIn("SDL_VIDEODRIVER", popen.call_args.kwargs["env"])
self.assertNotIn(
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR",
popen.call_args.kwargs["env"],
)
self.assertEqual(
popen.call_args.kwargs["env"][
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
],
"1",
)
open_host_x11.assert_not_called()
select_wayland_gamescope.assert_called_once_with(
"gamescope",
environment=mock.ANY,
)
self.assertIn("native Wayland", stderr.getvalue())
self.assertIn("lazy-output", stderr.getvalue())
self.assertIn("login-window visibility", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_kubuntu_wayland_uses_xwayland_presentation_and_reports_policy(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
environment = {
"WAYLAND_DISPLAY": "wayland-0",
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "c" * 32,
}
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
contextlib.redirect_stderr(stderr),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0][:4],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
],
)
self.assertNotIn(
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE",
popen.call_args.kwargs["env"],
)
open_host_x11.assert_called_once_with()
tune_process_window.assert_called_once()
self.assertIn("KDE Wayland compatibility mode active", stderr.getvalue())
payloads = [
parse_window_diagnostic(line)
for line in stderr.getvalue().splitlines()
]
runtime_configured = next(
payload
for payload in payloads
if payload is not None and payload["diagnostic"] == "runtime_configured"
)
details = runtime_configured["details"]
assert isinstance(details, dict)
self.assertTrue(details["kde_session"])
self.assertTrue(details["kde_wayland_compatibility_mode"])
self.assertTrue(details["kde_wayland_xwayland_fallback"])
self.assertEqual(
details["backend_reason"],
"kde_wayland_xwayland_compatibility",
)
self.assertEqual(details["presentation_mode"], "sdl_composed")
def test_main_reports_an_empty_command(self) -> None:
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
status = main(["--width", "1280", "--height", "960"])
self.assertEqual(status, 1)
self.assertIn("cannot be empty", stderr.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,866 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.desktop_entry import escape_icon_path, quote_exec_argument
from ptlinux.launcher_wrapper import quote_shell_word
from scripts.repository_paths import PROJECT_ROOT, SOURCE_ROOT
from tools.sign_release_artifact import sign_release_artifact
class AioPackagingTests(unittest.TestCase):
def test_bootstrap_release_remains_reproducible_with_a_fixed_epoch(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
outputs = (root / "first", root / "second")
for output in outputs:
environment = os.environ.copy()
environment.pop("PTLINUX_VERSION", None)
environment.update(
{
"PTLINUX_OUTPUT_DIRECTORY": str(output),
"PTLINUX_PYTHON": sys.executable,
"SOURCE_DATE_EPOCH": "0",
}
)
built = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(built.returncode, 0, built.stderr)
first = {
path.name: path.read_bytes()
for path in outputs[0].iterdir()
if path.is_file()
}
second = {
path.name: path.read_bytes()
for path in outputs[1].iterdir()
if path.is_file()
}
self.assertEqual(first, second)
def test_packaged_installer_uses_the_compatible_top_level_source_layout(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
output = root / "dist"
build_environment = os.environ.copy()
build_environment.pop("PTLINUX_VERSION", None)
build_environment.update(
{
"PTLINUX_OUTPUT_DIRECTORY": str(output),
"PTLINUX_PYTHON": sys.executable,
"SOURCE_DATE_EPOCH": "0",
}
)
built = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=build_environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(built.returncode, 0, built.stderr)
release_name = (
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
)
archive = output / f"{release_name}.tar.gz"
extracted = root / "extracted"
with tarfile.open(archive, "r:gz") as bundle:
bundle.extractall(extracted, filter="data")
packaged_root = extracted / release_name
self.assertTrue((packaged_root / "ptlinux").is_dir())
self.assertFalse((packaged_root / "src").exists())
home = root / "home"
data_home = root / "desktop-data"
install_directory = root / "managed-install"
install_environment = os.environ.copy()
install_environment.pop("PTLINUX_INSTALL_DIR", None)
install_environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
installed = subprocess.run(
[
"bash",
str(packaged_root / "install.sh"),
"--install-dir",
str(install_directory),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=packaged_root,
env=install_environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(installed.returncode, 0, installed.stderr)
current = install_directory / "app/current"
self.assertTrue((current / "ptlinux/cli.py").is_file())
self.assertTrue((current / "ptdiag/logs.py").is_file())
def test_installer_dry_run_reports_destinations_without_writing(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "desktop-data"
install_directory = root / "managed-install"
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--install-dir",
str(install_directory),
"--dry-run",
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(f"Install data: {install_directory}", result.stdout)
self.assertIn("Skipping host dependency installation.", result.stdout)
self.assertFalse(install_directory.exists())
self.assertFalse(home.exists())
self.assertFalse(data_home.exists())
def test_installer_writes_path_independent_desktop_entry(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home with spaces"
data_home = root / "data with spaces"
home.mkdir()
fake_bin = root / "bin"
fake_bin.mkdir()
fake_umu = fake_bin / "umu-run"
fake_umu.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
fake_umu.chmod(0o755)
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": f"{fake_bin}:/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--skip-deps",
"--skip-doctor",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(fake_umu.resolve()), result.stdout)
self.assertIn(
f"Note: {home / '.local/bin'} is not in PATH for this shell.",
result.stdout,
)
output_lines = result.stdout.splitlines()
start_line = output_lines[output_lines.index("Start PristonTale EU with:") + 1]
self.assertEqual(
shlex.split(start_line),
[str(home / ".local/bin/pristontale-eu"), "gui"],
)
self.assertIn("export PATH=", result.stdout)
default_install = home / "Games/PristontaleEU"
self.assertTrue((default_install / "app/current/ptlinux").is_dir())
self.assertTrue((default_install / "app/current/ptlinux/umu.py").is_file())
self.assertTrue(
(default_install / "app/current/ptlinux/ui/dashboard.py").is_file()
)
self.assertTrue(
(
default_install
/ "app/current/ptlinux/ui/viewmodels/downloader.py"
).is_file()
)
self.assertTrue(
(default_install / "app/current/ptlinux/native/pt-identity-probe.exe").is_file()
)
self.assertTrue(
(default_install / "app/current/ptlinux/native/ptr-bcrypt-proxy.dll").is_file()
)
gamescope = (
default_install
/ "app/current/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertTrue(gamescope.is_file())
self.assertEqual(gamescope.stat().st_mode & 0o777, 0o755)
self.assertFalse(
(default_install / "app/current/ptlinux/desktop_entry.py").exists()
)
self.assertFalse(
(default_install / "app/current/ptlinux/launcher_wrapper.py").exists()
)
self.assertFalse(
(default_install / "app/current/ptlinux/world_fix.py").exists()
)
self.assertTrue((default_install / "app/current").is_symlink())
self.assertEqual(
(default_install / "app/current").resolve().stat().st_mode & 0o777,
0o755,
)
launcher = home / ".local/bin/pristontale-eu"
desktop = data_home / "applications/eu.pristontale.PTLinux.desktop"
icon = data_home / "pixmaps/eu.pristontale.PTLinux.png"
self.assertTrue(launcher.is_file())
self.assertEqual(
icon.read_bytes(),
(PROJECT_ROOT / "packaging/eu.pristontale.PTLinux.png").read_bytes(),
)
entry = desktop.read_text(encoding="utf-8")
exec_line = next(line for line in entry.splitlines() if line.startswith("Exec="))
icon_line = next(line for line in entry.splitlines() if line.startswith("Icon="))
self.assertEqual(
exec_line,
f"Exec={quote_exec_argument(str(launcher))} gui",
)
self.assertEqual(icon_line, f"Icon={escape_icon_path(str(icon))}")
self.assertIn("StartupWMClass=PristonTaleEU", entry)
self.assertNotIn("TryExec=", entry)
self.assertIn(
f"install_directory={quote_shell_word(str(default_install))}",
launcher.read_text(encoding="utf-8"),
)
self.assertIn(
'exec "$python_bin" -B -P -m ptlinux',
launcher.read_text(encoding="utf-8"),
)
arguments = shlex.split(exec_line.removeprefix("Exec="))
self.assertEqual(arguments, [str(launcher), "gui"])
arguments[1] = "--help"
launched = subprocess.run(
arguments,
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(launched.returncode, 0, launched.stderr)
self.assertIn("PristonTale EU all-in-one", launched.stdout)
stale_directory = root / "unpacked-old-installer"
stale_package = stale_directory / "ptlinux"
stale_package.mkdir(parents=True)
(stale_package / "__init__.py").write_text("", encoding="utf-8")
(stale_package / "__main__.py").write_text(
'print("STALE LAUNCHER WAS IMPORTED")\n',
encoding="utf-8",
)
isolated = subprocess.run(
[launcher, "--version"],
cwd=stale_directory,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(isolated.returncode, 0, isolated.stderr)
self.assertEqual(isolated.stdout.strip(), f"pristontale-eu {__version__}")
self.assertNotIn("STALE LAUNCHER", isolated.stdout)
def test_installer_persists_custom_install_directory(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "desktop data"
install_directory = root / "External $(touch INJECTED)'s Game"
home.mkdir()
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--install-dir",
str(install_directory),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((install_directory / "app/current/ptlinux").is_dir())
self.assertTrue((install_directory / "app/current").is_symlink())
launcher = home / ".local/bin/pristontale-eu"
wrapper = launcher.read_text(encoding="utf-8")
self.assertIn(
f"install_directory={quote_shell_word(str(install_directory))}",
wrapper,
)
runtime_environment = environment.copy()
runtime_environment.pop("PTLINUX_INSTALL_DIR", None)
runtime_environment["XDG_DATA_HOME"] = str(root / "different data home")
launched = subprocess.run(
[str(launcher), "--help"],
cwd=root,
env=runtime_environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(launched.returncode, 0, launched.stderr)
self.assertIn("PristonTale EU all-in-one", launched.stdout)
self.assertFalse((root / "INJECTED").exists())
def test_installer_preserves_the_previous_default_on_upgrade(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
home = root / "home"
data_home = root / "data"
legacy_install = data_home / "pristontale-eu"
legacy_game = (
legacy_install
/ "launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe"
)
legacy_game.parent.mkdir(parents=True)
legacy_game.write_bytes(b"existing managed game")
legacy_package = legacy_install / "app/ptlinux"
legacy_package.mkdir(parents=True)
(legacy_package / "__init__.py").write_text(
'__version__ = "0.4.7"\n',
encoding="utf-8",
)
settings_file = home / ".config/pristontale-eu/settings.json"
settings_file.parent.mkdir(parents=True)
settings_file.write_text(
json.dumps(
{
"borderless": False,
"runtime": "proton7",
"proton_sync": "esync",
}
),
encoding="utf-8",
)
environment = os.environ.copy()
environment.pop("PTLINUX_INSTALL_DIR", None)
environment.update(
{
"HOME": str(home),
"XDG_DATA_HOME": str(data_home),
"PATH": "/usr/bin:/bin",
"PTLINUX_PYTHON": sys.executable,
}
)
result = subprocess.run(
[
"bash",
str(PROJECT_ROOT / "install.sh"),
"--skip-deps",
"--skip-doctor",
"--skip-runtime-bootstrap",
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("preserving its previous data location", result.stdout)
launcher = home / ".local/bin/pristontale-eu"
self.assertIn(
f"install_directory={quote_shell_word(str(legacy_install))}",
launcher.read_text(encoding="utf-8"),
)
self.assertFalse((home / "Games/PristontaleEU/app").exists())
self.assertTrue((legacy_install / "app/current").is_symlink())
settings_result = subprocess.run(
[launcher, "--json", "settings", "--init"],
cwd=root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(settings_result.returncode, 0, settings_result.stderr)
migrated = json.loads(settings_result.stdout)["settings"]
self.assertFalse(migrated["borderless"])
self.assertEqual(migrated["runtime"], "ge-proton")
self.assertEqual(migrated["proton_sync"], "esync")
persisted = json.loads(settings_file.read_text(encoding="utf-8"))
self.assertEqual(persisted["runtime"], "ge-proton")
self.assertIs(persisted["ge_proton_default_migrated"], True)
def test_production_release_requires_a_signing_key(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment.pop("PTLINUX_SIGNING_KEY", None)
environment["PTLINUX_RELEASE_MODE"] = "production"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("PTLINUX_SIGNING_KEY is required", result.stderr)
self.assertFalse(output.exists())
def test_production_release_rejects_a_key_path_inside_the_checkout(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment["PTLINUX_RELEASE_MODE"] = "production"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
environment["PTLINUX_PYTHON"] = sys.executable
environment["PTLINUX_SIGNING_KEY"] = str(
PROJECT_ROOT / "release-keys/fixture-private.pem"
)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("outside the repository", result.stderr)
self.assertFalse(output.exists())
def test_release_artifact_signature_is_generated_and_verified(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
private_key = root / "private.pem"
public_key = root / "public.pem"
artifact = root / "installer.tar.gz"
artifact.write_bytes(b"release bytes")
commands = (
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
)
for command in commands:
generated = subprocess.run(
command,
capture_output=True,
text=True,
timeout=20,
check=False,
)
self.assertEqual(generated.returncode, 0, generated.stderr)
private_key.chmod(0o600)
signature = sign_release_artifact(
artifact,
signing_key=private_key,
public_key=public_key,
)
self.assertTrue(signature.is_file())
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
artifact.write_bytes(b"tampered release bytes")
verification = subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-verify",
str(public_key),
"-signature",
str(signature),
str(artifact),
],
capture_output=True,
text=True,
timeout=20,
check=False,
)
self.assertNotEqual(verification.returncode, 0)
def test_release_builder_packages_current_updater_sources(self) -> None:
self.assertIn(
f'version = "{__version__}"',
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"),
)
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory)
environment = os.environ.copy()
environment.pop("PTLINUX_VERSION", None)
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
environment["SOURCE_DATE_EPOCH"] = "0"
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
release_name = (
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
)
archive = output / f"{release_name}.tar.gz"
checksum = archive.with_suffix(archive.suffix + ".sha256")
self.assertTrue(archive.is_file())
self.assertTrue(checksum.is_file())
self.assertFalse(Path(f"{archive}.sig").exists())
self.assertTrue((output / "launcher-update-public.pem").is_file())
zip_archive = output / f"{release_name}.zip"
self.assertTrue(zip_archive.is_file())
self.assertFalse(Path(f"{zip_archive}.sig").exists())
update_archive = output / f"pristontale-eu-linux-update-v{__version__}.tar.gz"
self.assertTrue(update_archive.is_file())
update_checksum = update_archive.with_suffix(update_archive.suffix + ".sha256")
self.assertTrue(update_checksum.is_file())
manifest = output / "stable.json"
self.assertTrue(manifest.is_file())
for artifact in (
archive,
checksum,
update_archive,
update_checksum,
manifest,
):
self.assertEqual(artifact.stat().st_mode & 0o777, 0o644)
expected_sha256 = checksum.read_text(encoding="utf-8").split()[0]
self.assertEqual(
hashlib.sha256(archive.read_bytes()).hexdigest(),
expected_sha256,
)
release_root = release_name
with tarfile.open(archive, "r:gz") as release:
members = release.getmembers()
names = {member.name for member in members}
self.assertTrue(
all(member.mode & 0o022 == 0 for member in members),
"release entries must not be group- or world-writable",
)
self.assertEqual(
next(member.mode for member in members if member.name == release_root),
0o755,
)
self.assertEqual(
{name for name in names if name.endswith(".md")},
{f"{release_root}/README.md"},
)
self.assertIn(f"{release_root}/ptlinux/desktop_entry.py", names)
self.assertIn(f"{release_root}/ptlinux/install_status.py", names)
self.assertIn(f"{release_root}/ptlinux/ui/dashboard.py", names)
self.assertIn(
f"{release_root}/ptlinux/ui/components/progress_panel.py",
names,
)
self.assertIn(f"{release_root}/ptlinux/launcher_wrapper.py", names)
self.assertIn(f"{release_root}/ptlinux/self_update.py", names)
self.assertIn(f"{release_root}/ptlinux/container_host.py", names)
self.assertIn(f"{release_root}/release/config.json", names)
self.assertIn(f"{release_root}/scripts/__init__.py", names)
self.assertIn(f"{release_root}/scripts/repository_paths.py", names)
self.assertIn(f"{release_root}/scripts/release_lib.py", names)
self.assertFalse(any(name.startswith(f"{release_root}/devtools/") for name in names))
self.assertIn(
f"{release_root}/ptlinux/launcher-update-public.pem",
names,
)
self.assertIn(
f"{release_root}/ptlinux/native/pt-identity-probe.exe",
names,
)
self.assertIn(
f"{release_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
names,
)
gamescope_name = (
f"{release_root}/ptlinux/native/"
"gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertIn(gamescope_name, names)
self.assertEqual(
next(member.mode for member in members if member.name == gamescope_name),
0o755,
)
for name in (
"gamescope-compat.README",
"gamescope-compat.copyright",
"gamescope-lazy-output-images.patch",
"gamescope-wayland-live-resize.patch",
"gamescope-wayland-letterbox-backing.patch",
"gamescope-wayland-libdecor-input-scope.patch",
"gamescope-keep-nested-windows-visible.patch",
):
self.assertIn(f"{release_root}/ptlinux/native/{name}", names)
self.assertNotIn(
f"{release_root}/ptlinux/native/pt-world-fix.exe",
names,
)
self.assertNotIn(f"{release_root}/ptlinux/world_fix.py", names)
archived_readme = release.extractfile(f"{release_root}/README.md")
self.assertIsNotNone(archived_readme)
assert archived_readme is not None
readme = archived_readme.read().decode("utf-8")
self.assertIn(release_name, readme)
self.assertIn("./install.sh", readme)
self.assertIn("pristontale-eu update --repair", readme)
self.assertIn("pristontale-eu --help", readme)
for command in (
"gui",
"doctor",
"container-host",
"fetch",
"setup",
"update",
"sync",
"wine-update",
"import-game",
"cache-compatibility",
"prepare",
"play",
"settings",
"launcher-update",
):
self.assertIn(f"pristontale-eu {command}", readme)
archived_depot = release.extractfile(f"{release_root}/ptlinux/depot.py")
self.assertIsNotNone(archived_depot)
assert archived_depot is not None
self.assertEqual(
archived_depot.read(),
(SOURCE_ROOT / "ptlinux/depot.py").read_bytes(),
)
archived_login_trace = release.extractfile(
f"{release_root}/ptlinux/login_trace.py"
)
self.assertIsNotNone(archived_login_trace)
assert archived_login_trace is not None
login_trace_source = archived_login_trace.read().decode("utf-8")
self.assertIn("LOGIN_TRACE_ENABLED = False", login_trace_source)
self.assertNotIn("+relay", login_trace_source)
self.assertNotIn("RelayInclude", login_trace_source)
with tarfile.open(update_archive, "r:gz") as update:
update_root = f"pristontale-eu-linux-update-v{__version__}"
update_members = update.getmembers()
update_names = {member.name for member in update_members}
self.assertIn(f"{update_root}/release.json", update_names)
self.assertIn(f"{update_root}/ptlinux/self_update.py", update_names)
self.assertIn(f"{update_root}/ptlinux/container_host.py", update_names)
self.assertIn(f"{update_root}/ptlinux/import_state.py", update_names)
self.assertIn(f"{update_root}/ptlinux/ui/dashboard.py", update_names)
self.assertIn(
f"{update_root}/ptlinux/ui/dialogs/settings_dialog.py",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/desktop_entry.py",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/launcher_wrapper.py",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/launcher-update-public.pem",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/native/pt-identity-probe.exe",
update_names,
)
self.assertIn(
f"{update_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
update_names,
)
gamescope_name = (
f"{update_root}/ptlinux/native/"
"gamescope-3.16.20-ubuntu26.04-amd64"
)
self.assertIn(gamescope_name, update_names)
self.assertEqual(
next(
member.mode
for member in update_members
if member.name == gamescope_name
),
0o755,
)
for name in (
"gamescope-compat.README",
"gamescope-compat.copyright",
"gamescope-lazy-output-images.patch",
"gamescope-wayland-live-resize.patch",
"gamescope-wayland-letterbox-backing.patch",
"gamescope-wayland-libdecor-input-scope.patch",
"gamescope-keep-nested-windows-visible.patch",
):
self.assertIn(
f"{update_root}/ptlinux/native/{name}",
update_names,
)
self.assertNotIn(
f"{update_root}/ptlinux/native/pt-world-fix.exe",
update_names,
)
self.assertNotIn(f"{update_root}/ptlinux/world_fix.py", update_names)
def test_release_builder_rejects_unsafe_version_text(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "output"
environment = os.environ.copy()
environment["PTLINUX_VERSION"] = "../unsafe"
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
result = subprocess.run(
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("expected X.Y.Z", result.stderr)
self.assertFalse(output.exists())
def test_compact_release_can_import_overlay_in_clean_interpreter(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
release_root = Path(temporary_directory)
shutil.copytree(SOURCE_ROOT / "ptlinux", release_root / "ptlinux")
(release_root / "ptdiag").mkdir()
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "__init__.py",
release_root / "ptdiag" / "__init__.py",
)
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "assbin.py",
release_root / "ptdiag" / "assbin.py",
)
shutil.copy2(
SOURCE_ROOT / "ptdiag" / "logs.py",
release_root / "ptdiag" / "logs.py",
)
environment = os.environ.copy()
environment["PYTHONPATH"] = str(release_root)
result = subprocess.run(
[sys.executable, "-c", "import ptlinux.overlay"],
cwd=release_root,
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import contextlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.window_diagnostics import (
WINDOW_DIAGNOSTIC_EVENT_LIMIT,
WINDOW_DIAGNOSTIC_PREFIX,
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticLog,
WindowDiagnosticRecorder,
parse_window_diagnostic,
)
class WindowDiagnosticTests(unittest.TestCase):
def test_local_log_keeps_bounded_samples_and_summaries_from_this_session(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "logs" / "windows-session.jsonl"
log = WindowDiagnosticLog(path, "a" * 32)
for component in ("game_window", "gamescope_host"):
payload = {
"component": component,
"session_id": "a" * 32,
"diagnostic": "focus_state_changed",
}
log.record({**payload, "session_id": "b" * 32})
for sequence in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 10):
log.record({**payload, "sequence": sequence + 1})
for _ in range(2):
log.record({**payload, "diagnostic": "session_summary"})
payloads = [json.loads(line) for line in path.read_text().splitlines()]
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual({item["session_id"] for item in payloads}, {"a" * 32})
for component in ("game_window", "gamescope_host"):
records = [item for item in payloads if item["component"] == component]
self.assertEqual(len(records), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
self.assertEqual(records[-1]["diagnostic"], "session_summary")
def test_local_log_io_failure_warns_once_without_interrupting_play(self) -> None:
log = WindowDiagnosticLog(Path("/unused/windows-session.jsonl"), "a" * 32)
payload = {"component": "game_window", "session_id": "a" * 32, "diagnostic": "focus"}
output = io.StringIO()
with (
patch("ptlinux.window_diagnostics.Path.mkdir", side_effect=OSError("disk full")) as mkdir,
contextlib.redirect_stderr(output),
):
log.record(payload)
log.record(payload)
mkdir.assert_called_once()
self.assertEqual(output.getvalue().count("could not be saved locally"), 1)
def test_recorder_bounds_samples_and_always_emits_a_summary(self) -> None:
environment = {
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "a" * 32,
}
output = io.StringIO()
with contextlib.redirect_stderr(output):
recorder = WindowDiagnosticRecorder(
"gamescope_host",
environment=environment,
monotonic=lambda: 10.0,
)
for index in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8):
recorder.record("host_inactive_remap", attempt=index)
recorder.close(minimized_latched=True)
payloads = [
parse_window_diagnostic(line)
for line in output.getvalue().splitlines()
]
self.assertNotIn(None, payloads)
self.assertEqual(len(payloads), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
summary = payloads[-1]
assert summary is not None
self.assertEqual(summary["diagnostic"], "session_summary")
self.assertEqual(summary["session_id"], "a" * 32)
details = summary["details"]
assert isinstance(details, dict)
self.assertEqual(
details["counts"],
{"host_inactive_remap": WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8},
)
self.assertEqual(details["sampled_events"], WINDOW_DIAGNOSTIC_EVENT_LIMIT)
self.assertEqual(details["suppressed_events"], 8)
def test_transition_records_only_state_changes(self) -> None:
output = io.StringIO()
with contextlib.redirect_stderr(output):
recorder = WindowDiagnosticRecorder(
"game_window",
environment={WINDOW_DIAGNOSTICS_ENV: "true"},
monotonic=lambda: 1.0,
)
recorder.transition("focus", (False, False), "focus_state_changed")
recorder.transition("focus", (False, False), "focus_state_changed")
recorder.transition("focus", (True, True), "focus_state_changed")
recorder.close()
payloads = [
json.loads(line.removeprefix(WINDOW_DIAGNOSTIC_PREFIX))
for line in output.getvalue().splitlines()
]
self.assertEqual(
[payload["diagnostic"] for payload in payloads],
["focus_state_changed", "focus_state_changed", "session_summary"],
)
self.assertEqual(payloads[-1]["details"]["counts"], {"focus_state_changed": 2})
def test_parser_rejects_untrusted_or_malformed_records(self) -> None:
self.assertIsNone(parse_window_diagnostic("ordinary game output\n"))
self.assertIsNone(
parse_window_diagnostic(
WINDOW_DIAGNOSTIC_PREFIX
+ '{"schema":1,"component":"other","diagnostic":"state",'
+ '"sequence":1,"elapsed_ms":0,"details":{}}\n'
)
)
self.assertIsNone(
parse_window_diagnostic(
WINDOW_DIAGNOSTIC_PREFIX + "{not-json}\n"
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,514 @@
"""Focus ownership and supervisor failure-path regressions (no live desktop)."""
from contextlib import ExitStack
import ctypes
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.presentation import game_window as gw
class Clock:
def __init__(self) -> None:
self.now = 0.0
def sleep(self, seconds: float) -> None:
self.now += seconds
class WindowFocusRegressions(unittest.TestCase):
def setUp(self) -> None:
self.context = self.enterContext(ExitStack())
self.clock = Clock()
self.context.enter_context(patch.object(gw.time, "sleep", self.clock.sleep))
self.context.enter_context(patch.object(gw.time, "monotonic", lambda: self.clock.now))
self.mapper = MagicMock(spec=gw._X11)
self.mapper.window_state.return_value = 1
self.mapper.window_is_active.return_value = True
self.mapper.window_is_focused.return_value = False
self.mapper.activate_window.return_value = True
def assert_no_input_change(self) -> None:
self.mapper.focus_window.assert_not_called()
self.mapper.restore_input_focus.assert_not_called()
def test_denied_desktop_activation_is_not_overridden_with_raw_focus(self) -> None:
self.mapper.window_is_active.return_value = False
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_called_once_with(12)
self.assertLessEqual(self.clock.now, 2.0)
def test_slow_desktop_activation_is_confirmed_before_input_repair(self) -> None:
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 0.6
self.mapper.window_is_focused.side_effect = [False, True]
gw._focus_game_window(self.mapper, 12)
self.assertGreaterEqual(self.clock.now, 0.6)
self.assertLessEqual(self.clock.now, 2.0)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.focus_window.assert_not_called()
def test_already_focused_child_is_not_replaced_by_startup_handoff(self) -> None:
self.mapper.window_is_focused.return_value = True
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_alt_tab_during_focus_inspection_is_not_undone(self) -> None:
self.mapper.window_is_active.side_effect = [True, False]
gw._repair_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_unmapped_or_unknown_window_is_never_focused(self) -> None:
for state in (None, 0, 2, 3):
for repair in (gw._repair_game_window_focus, gw._repair_gamescope_game_window_focus):
with self.subTest(state=state, repair=repair.__name__):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
with patch.object(gw, "_gamescope_host_focus_is_allowed", return_value=True):
self.assertFalse(repair(self.mapper, 12))
self.assert_no_input_change()
def test_nested_handoff_rechecks_the_host_after_the_dashboard_delay(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._focus_game_window(self.mapper, 12, activate=False)
self.assert_no_input_change()
self.mapper.map_window.assert_not_called()
def test_nested_startup_maps_the_ge_client_before_repairing_input(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
mapped_at = None
def map_window(_window, *, borderless):
nonlocal mapped_at
self.assertFalse(borderless)
mapped_at = self.clock.now
self.mapper.map_window.side_effect = map_window
self.mapper.window_state.side_effect = lambda _window: (
1 if mapped_at is not None and self.clock.now - mapped_at >= 0.1 else None
)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.activate_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_nested_startup_does_not_remap_existing_or_destroyed_windows(self) -> None:
for state, size in ((1, (1920, 1080)), (3, (1920, 1080)), (0, (1920, 1080)), (None, None)):
with self.subTest(state=state, size=size):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
self.mapper.window_size.return_value = size
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.mapper.activate_window.assert_not_called()
def test_nested_startup_does_not_map_after_host_focus_changes(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False}, {"allowed": False},
]):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_startup_mapping_wait_is_bounded_and_does_not_force_focus(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assertLessEqual(self.clock.now, 1.0)
self.assert_no_input_change()
def test_nested_repair_rechecks_host_focus_before_setting_input(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False},
]):
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_nested_mapping_is_deferred_until_host_focus_returns(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
mapped_at = []
def map_window(_window, *, borderless):
mapped_at.append(self.clock.now)
self.mapper.window_state.return_value = 1
self.mapper.map_window.side_effect = map_window
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", side_effect=lambda **_kw: {
"allowed": self.clock.now >= 1.0,
}):
gw._focus_game_window(self.mapper, 12, activate=False)
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.assertEqual(len(mapped_at), 1)
self.assertGreaterEqual(mapped_at[0], 1.0)
self.mapper.activate_window.assert_not_called()
self.mapper.restore_input_focus.assert_called_with(12)
def test_nested_replacement_login_window_gets_its_own_startup_mapping(self) -> None:
mapped = set()
self.mapper.window_state.side_effect = lambda window: (
1 if window in mapped or (window == 12 and self.clock.now < 0.5) else None
)
self.mapper.window_size.side_effect = lambda window: (
(1920, 1080) if window == 21 else None
)
self.mapper.find_first_window.return_value = (21, "PristonTale EU Login")
self.mapper.map_window.side_effect = lambda window, **_kw: mapped.add(window)
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(21, borderless=False)
self.mapper.restore_input_focus.assert_called_with(21)
def test_nested_monitor_does_not_keep_remapping_an_unacknowledged_client(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 3 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_mapping_stays_deferred_while_the_host_is_minimized(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_monitor_does_not_resurrect_a_previously_managed_client(self) -> None:
self.mapper.window_state.side_effect = lambda _window: 1 if self.clock.now < 0.5 else None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.window_is_focused.return_value = True
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_mapping_error_does_not_end_the_game_supervisor(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.map_window.side_effect = OSError("window disappeared")
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
self.assertEqual(gw._maintain_gamescope_game_window_focus(self.mapper, process, 12), 0)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_startup_rechecks_minimize_before_mapping(self) -> None:
self.mapper.window_state.side_effect = [None, 3]
self.mapper.window_size.return_value = (1920, 1080)
prepared_windows = set()
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
# Losing WM_STATE after minimization must not trigger startup mapping.
self.mapper.window_state.side_effect = None
self.mapper.window_state.return_value = None
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_activation_errors_do_not_fall_back_to_forced_focus(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.activate_window.side_effect = RuntimeError("WM disconnected")
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
def test_initial_preparation_respects_minimize_after_discovery(self) -> None:
self.mapper.root_window_ids.return_value = set()
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.return_value = None
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_maintain_game_window_focus", return_value=17),
):
self.assertEqual(gw.run_game_window(["unused"], borderless=True), 17)
self.mapper.map_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_preparation_never_raises_a_client_before_desktop_activation(self) -> None:
gw._prepare_game_window(self.mapper, 12,
borderless=False, suppress_wm_ping=False, raise_window=False)
self.mapper.map_window.assert_called_once_with(12, borderless=False, raise_window=False)
self.assert_no_input_change()
def test_layout_recovery_does_not_remap_or_raise_the_x11_client(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
mapper.library = MagicMock()
mapper.display = ctypes.c_void_p(1)
with (
patch.object(mapper, "window_size", return_value=(1280, 720)),
patch.object(mapper, "_remove_decorations"),
):
self.assertEqual(mapper.map_window(12, borderless=True, raise_window=False), (1280, 720))
mapper.library.XMapRaised.assert_not_called()
mapper.library.XMapWindow.assert_not_called()
mapper.library.XSetInputFocus.assert_not_called()
def test_bad_bridge_timestamps_do_not_crash_the_supervisor(self) -> None:
with tempfile.TemporaryDirectory() as directory:
state = Path(directory) / "focus"
environment = {gw.GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state)}
for timestamp in ("nan", "inf", "-inf", "1e999", "1e308", "-1e308"):
with self.subTest(timestamp=timestamp):
state.write_text(f"1 {timestamp}\n", encoding="ascii")
self.assertFalse(gw._gamescope_host_focus_is_allowed(environment=environment, now=10))
def test_startup_recovery_does_not_activate_a_background_window(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
with patch.object(gw, "_prepare_game_window") as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
prepare.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_startup_recovery_does_not_undo_alt_tab_during_surface_refresh(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
def switch_away(*_args, **_kwargs) -> None:
self.mapper.window_is_active.return_value = False
with patch.object(gw, "_prepare_game_window", side_effect=switch_away) as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertIs(prepare.call_args.kwargs["raise_window"], False)
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_scaling_failures_keep_the_focus_supervisor_running(self) -> None:
for failure in ("subscribe", "configure", "poll", "repair"):
with self.subTest(failure=failure):
mapper = MagicMock(spec=gw._X11)
mapper.enable_window_scaling.return_value = failure != "configure"
if failure == "subscribe":
mapper.select_scaling_hint_events.side_effect = RuntimeError("subscription failed")
elif failure == "poll":
mapper.wait_for_scaling_hint_change.side_effect = RuntimeError("poll failed")
elif failure == "repair":
mapper.enable_window_scaling.side_effect = [True] + [RuntimeError("hints failed")] * 3
process = MagicMock()
process.poll.return_value = None
with patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain:
self.assertEqual(gw._maintain_game_window_scaling(mapper, process, 12), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids=(), expected_pid=None,
)
process.wait.assert_not_called()
def test_delayed_desktop_activation_is_repaired_during_startup_guard(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = False
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 1.5
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 4 else 0
repair_times: list[float] = []
def repair(_window: int) -> None:
repair_times.append(self.clock.now)
self.mapper.window_is_focused.return_value = True
self.mapper.restore_input_focus.side_effect = repair
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertEqual(len(repair_times), 1)
self.assertGreaterEqual(repair_times[0], 1.5)
self.assertLess(repair_times[0], 4)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_startup_guard_hands_a_vanished_window_to_the_monitor_immediately(self) -> None:
process = MagicMock()
process.poll.return_value = None
for state in (None, 0):
with self.subTest(state=state):
self.mapper.window_state.return_value = state
self.assertFalse(gw._guard_game_window_startup(
self.mapper, process, 12, borderless=False,
))
self.assertEqual(self.clock.now, 0.0)
self.assert_no_input_change()
def test_direct_monitor_follows_the_same_process_without_resizing_or_activating(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: 0 if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.restore_input_focus.side_effect = lambda _window: setattr(
self.mapper.window_is_focused, "return_value", True,
)
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 3 else 17
process.wait.return_value = 17
self.assertEqual(gw._maintain_game_window_focus(
self.mapper, process, 12, expected_pid=42, existing_window_ids={8, 9},
), 17)
self.mapper.find_first_window.assert_called_once_with(
gw.WINDOW_TITLE, excluded_window_ids={8, 9, 12}, expected_pid=42,
)
self.mapper.restore_input_focus.assert_called_once_with(22)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_a_replaced_background_window_does_not_take_keyboard_focus(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: None if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.window_is_active.return_value = False
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_minimized_direct_client_is_not_replaced_or_restored(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.mapper.find_first_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.restore_normal_window_state.assert_not_called()
self.assert_no_input_change()
def test_missing_process_identity_does_not_attach_to_another_account(self) -> None:
self.mapper.window_pid.return_value = None
self.mapper.window_state.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12)
self.mapper.find_first_window.assert_not_called()
self.assert_no_input_change()
def test_reused_window_id_from_another_process_is_not_focused(self) -> None:
self.mapper.window_pid.return_value = 99
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
for call in self.mapper.find_first_window.call_args_list:
self.assertEqual(call.kwargs["expected_pid"], 42)
self.assertIn(12, call.kwargs["excluded_window_ids"])
def test_discovery_keeps_the_original_pid_if_the_window_disappears_during_startup(self) -> None:
self.mapper.root_window_ids.return_value = {8, 9}
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_pid.return_value = 42
process = MagicMock()
process.poll.return_value = None
def disappear(*_args, **_kwargs) -> bool:
self.mapper.window_pid.return_value = None
return False
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_prepare_game_window"),
patch.object(gw, "_focus_game_window"),
patch.object(gw, "_guard_game_window_startup", side_effect=disappear),
patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(gw.run_game_window(["unused"]), 17)
maintain.assert_called_once_with(
self.mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids={8, 9}, expected_pid=42,
)
class ClientWindowDiscoveryTests(unittest.TestCase):
def test_replacement_discovery_rejects_other_processes_with_identical_titles(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50, 60, 70]),
patch.object(mapper, "_client_window", side_effect={50: 12, 60: 13, 70: 14}.__getitem__),
patch.object(mapper, "window_pid", side_effect={12: 99, 13: None, 14: 42}.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertEqual(mapper.find_first_window(
gw.WINDOW_TITLE, excluded_window_ids=set(), expected_pid=42,
), (14, "PristonTale EU V23.86.96"))
def test_title_is_read_from_the_client_inside_an_untitled_wm_frame(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", side_effect=lambda window:
"PristonTale EU V23.86.96" if window == 12 else None),
):
self.assertEqual(mapper.find_first_window("PristonTale EU", excluded_window_ids=set()),
(12, "PristonTale EU V23.86.96"))
def test_an_existing_client_is_excluded_even_if_its_frame_changes(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
frames = {50: 12, 60: 12, 70: 13}
with (
patch.object(mapper, "_root_windows", side_effect=[[50], [60, 70]]),
patch.object(mapper, "_client_window", side_effect=frames.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
existing = mapper.root_window_ids()
found = mapper.find_first_window("PristonTale EU", excluded_window_ids=existing)
self.assertIn(12, existing)
self.assertEqual(found, (13, "PristonTale EU V23.86.96"))
def test_a_retired_client_is_excluded_even_when_a_frame_supplies_its_title(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertIsNone(mapper.find_first_window("PristonTale EU", excluded_window_ids={12}))

202
tools/build-aio-release.sh Executable file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
PACKAGE_SOURCE_ROOT="${PROJECT_ROOT}/src"
GAMESCOPE_PROVENANCE_ROOT="${PROJECT_ROOT}/native/gamescope"
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${PACKAGE_SOURCE_ROOT}/ptlinux/__init__.py")"
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
printf 'Invalid release version: %s (expected X.Y.Z)\n' "$VERSION" >&2
exit 2
fi
if [[ "$VERSION" != "$SOURCE_VERSION" ]]
then
printf 'Release version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2
exit 2
fi
RELEASE_MODE="${PTLINUX_RELEASE_MODE:-development}"
if [[ "$RELEASE_MODE" != "development" && "$RELEASE_MODE" != "production" ]]
then
printf 'Invalid release mode: %s (expected development or production)\n' "$RELEASE_MODE" >&2
exit 2
fi
VERSION_SLUG="${VERSION//./-}"
ARCHIVE_NAME="pristontale-eu-linux-installer-v${VERSION_SLUG}"
OUTPUT_DIRECTORY="${PTLINUX_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/aio}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
if [[ "$RELEASE_MODE" == "production" && -z "${PTLINUX_SIGNING_KEY:-}" ]]
then
printf '%s\n' "PTLINUX_SIGNING_KEY is required for a production release." >&2
exit 2
fi
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
PTLINUX_SIGNING_KEY="$(
PYTHONPATH="${PROJECT_ROOT}/src:${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" \
"$PYTHON_BIN" -c \
'import sys
from pathlib import Path
from scripts.release_lib import ReleaseToolError, _safe_signing_key_path
try:
print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2])))
except ReleaseToolError as exc:
print(f"Invalid release signing key: {exc}", file=sys.stderr)
raise SystemExit(2)' \
"$PTLINUX_SIGNING_KEY" "$OUTPUT_DIRECTORY"
)"
export PTLINUX_SIGNING_KEY
fi
STAGING_DIRECTORY="$(mktemp -d /tmp/ptlinux-release.XXXXXX)"
RELEASE_ROOT="${STAGING_DIRECTORY}/${ARCHIVE_NAME}"
cleanup()
{
rm -r -- "$STAGING_DIRECTORY"
}
trap cleanup EXIT
install -d -m 0755 "$RELEASE_ROOT"
install -d -m 0755 \
"$RELEASE_ROOT/ptlinux" \
"$RELEASE_ROOT/ptlinux/native" \
"$RELEASE_ROOT/ptdiag" \
"$RELEASE_ROOT/packaging" \
"$RELEASE_ROOT/release" \
"$RELEASE_ROOT/scripts"
shopt -s globstar nullglob
for SOURCE_MODULE in "${PACKAGE_SOURCE_ROOT}/ptlinux/"**/*.py
do
RELATIVE_MODULE="${SOURCE_MODULE#"${PACKAGE_SOURCE_ROOT}/ptlinux/"}"
install -D -m 0644 \
"$SOURCE_MODULE" \
"${RELEASE_ROOT}/ptlinux/${RELATIVE_MODULE}"
done
shopt -u globstar nullglob
install -m 0644 \
"${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"$RELEASE_ROOT/ptlinux/launcher-update-public.pem"
install -m 0644 \
"${PACKAGE_SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
"$RELEASE_ROOT/ptlinux/native/pt-identity-probe.exe"
install -m 0644 \
"${PACKAGE_SOURCE_ROOT}/ptlinux/native/ptr-bcrypt-proxy.dll" \
"$RELEASE_ROOT/ptlinux/native/ptr-bcrypt-proxy.dll"
install -m 0755 \
"${PACKAGE_SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \
"$RELEASE_ROOT/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
install -m 0644 \
"${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.README" \
"${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.copyright" \
"${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-lazy-output-images.patch" \
"${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-live-resize.patch" \
"${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-letterbox-backing.patch" \
"${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-libdecor-input-scope.patch" \
"${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-keep-nested-windows-visible.patch" \
"$RELEASE_ROOT/ptlinux/native/"
install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/__init__.py" "$RELEASE_ROOT/ptdiag/__init__.py"
install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/assbin.py" "$RELEASE_ROOT/ptdiag/assbin.py"
install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/logs.py" "$RELEASE_ROOT/ptdiag/logs.py"
install -m 0644 \
"${PROJECT_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${PROJECT_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"${PROJECT_ROOT}/packaging/install-dependencies.sh" \
"$RELEASE_ROOT/packaging/"
install -m 0755 "${PROJECT_ROOT}/packaging/pristontale-eu" "$RELEASE_ROOT/packaging/pristontale-eu"
install -m 0644 \
"${PROJECT_ROOT}/release/config.json" \
"$RELEASE_ROOT/release/config.json"
install -m 0644 \
"${PROJECT_ROOT}/scripts/__init__.py" \
"${PROJECT_ROOT}/scripts/repository_paths.py" \
"${PROJECT_ROOT}/scripts/release_lib.py" \
"$RELEASE_ROOT/scripts/"
install -m 0755 "${PROJECT_ROOT}/install.sh" "$RELEASE_ROOT/install.sh"
install -m 0644 "${PROJECT_ROOT}/RELEASE_README.md" "$RELEASE_ROOT/README.md"
install -d "$OUTPUT_DIRECTORY"
tar \
--sort=name \
--owner=0 \
--group=0 \
--numeric-owner \
--mtime="@${SOURCE_DATE_EPOCH:-0}" \
-C "$STAGING_DIRECTORY" \
-czf "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" \
"$ARCHIVE_NAME"
chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz"
(
cd "$OUTPUT_DIRECTORY"
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
)
chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz.sha256"
if ! command -v zip >/dev/null 2>&1
then
printf 'Missing release dependency: zip\n' >&2
exit 1
fi
ZIP_TIMESTAMP_EPOCH="${SOURCE_DATE_EPOCH:-315532800}"
if (( ZIP_TIMESTAMP_EPOCH < 315532800 ))
then
# ZIP timestamps cannot represent dates before 1980-01-01.
ZIP_TIMESTAMP_EPOCH=315532800
fi
find "$RELEASE_ROOT" -exec touch -h -d "@${ZIP_TIMESTAMP_EPOCH}" {} +
(
cd "$STAGING_DIRECTORY"
LC_ALL=C find "$ARCHIVE_NAME" -print \
| LC_ALL=C sort \
| zip -X -q "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip" -@
)
chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip"
(
cd "$OUTPUT_DIRECTORY"
sha256sum "${ARCHIVE_NAME}.zip" > "${ARCHIVE_NAME}.zip.sha256"
)
chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip.sha256"
PUBLIC_KEY_ARTIFACT="${OUTPUT_DIRECTORY}/launcher-update-public.pem"
install -m 0644 \
"${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"$PUBLIC_KEY_ARTIFACT"
for installer_archive in \
"${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" \
"${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip"
do
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
"$PYTHON_BIN" "${PROJECT_ROOT}/tools/sign_release_artifact.py" \
--signing-key "$PTLINUX_SIGNING_KEY" \
--public-key "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"$installer_archive"
else
rm -f -- "${installer_archive}.sig"
fi
done
UPDATE_COMMAND=(
"$PYTHON_BIN"
"${PROJECT_ROOT}/tools/build_launcher_update.py"
--version "$VERSION"
--output-directory "$OUTPUT_DIRECTORY"
--repository-url "${PTLINUX_REPOSITORY_URL:-https://git.m4ko.net/m4ko/pristontale-eu-linux}"
)
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]
then
UPDATE_COMMAND+=(--source-date-epoch "$SOURCE_DATE_EPOCH")
fi
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
UPDATE_COMMAND+=(--signing-key "$PTLINUX_SIGNING_KEY")
fi
if [[ "$RELEASE_MODE" == "production" ]]
then
UPDATE_COMMAND+=(--require-signature)
fi
"${UPDATE_COMMAND[@]}"
printf '%s\n' "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz"

103
tools/build_launcher_update.py Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Compatibility entry point for the deterministic native-launcher update."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
import time
from typing import Sequence
# This file remains at its historical path for release scripts and imports. The
# implementation belongs to the canonical release library under scripts/.
_BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1]
if str(_BOOTSTRAP_ROOT) not in sys.path:
sys.path.insert(0, str(_BOOTSTRAP_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
build_legacy_launcher_update,
)
from scripts.repository_paths import PROJECT_ROOT # noqa: E402
DEFAULT_REPOSITORY_URL = "https://git.m4ko.net/m4ko/pristontale-eu-linux"
DEFAULT_MINIMUM_UPDATER_VERSION = "0.5.0"
ReleaseBuildError = ReleaseToolError
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--output-directory", type=Path, required=True)
parser.add_argument(
"--repository-url",
default=DEFAULT_REPOSITORY_URL,
help="public Gitea repository base URL",
)
parser.add_argument(
"--minimum-updater-version",
default=DEFAULT_MINIMUM_UPDATER_VERSION,
)
parser.add_argument("--signing-key", type=Path)
parser.add_argument(
"--require-signature",
action="store_true",
help="fail unless a signing key is provided and matches the bundled public key",
)
parser.add_argument(
"--source-date-epoch",
type=int,
default=int(os.environ.get("SOURCE_DATE_EPOCH", str(int(time.time())))),
)
return parser
def build_launcher_update(
*,
version: str,
output_directory: Path,
repository_url: str,
minimum_updater_version: str,
signing_key: Path | None,
require_signature: bool = False,
source_date_epoch: int,
) -> tuple[Path, ...]:
"""Forward the historical Python API to the canonical release library."""
return build_legacy_launcher_update(
version=version,
output_directory=output_directory,
repository_url=repository_url,
minimum_updater_version=minimum_updater_version,
signing_key=signing_key,
require_signature=require_signature,
source_date_epoch=source_date_epoch,
)
def main(argv: Sequence[str] | None = None) -> int:
arguments = build_parser().parse_args(argv)
try:
artifacts = build_launcher_update(
version=arguments.version,
output_directory=arguments.output_directory,
repository_url=arguments.repository_url,
minimum_updater_version=arguments.minimum_updater_version,
signing_key=arguments.signing_key,
require_signature=arguments.require_signature,
source_date_epoch=arguments.source_date_epoch,
)
except (OSError, ReleaseBuildError) as exc:
print(f"Could not build launcher update: {exc}", file=sys.stderr)
return 1
for artifact in artifacts:
print(artifact)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Create and verify an atomic OpenSSL signature for one release artifact."""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Sequence
from uuid import uuid4
_BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1]
if str(_BOOTSTRAP_ROOT) not in sys.path:
sys.path.insert(0, str(_BOOTSTRAP_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_safe_signing_key_path,
)
class ReleaseSigningError(RuntimeError):
"""Raised when a release artifact cannot be signed safely."""
def sign_release_artifact(
artifact: Path,
*,
signing_key: Path,
public_key: Path,
) -> Path:
"""Sign an artifact and verify the result with the published public key."""
source = _regular_file(artifact, "release artifact")
try:
private = _safe_signing_key_path(signing_key)
except ReleaseToolError as exc:
raise ReleaseSigningError(str(exc)) from exc
public = _regular_file(public_key, "public key")
openssl = shutil.which("openssl")
if openssl is None:
raise ReleaseSigningError("OpenSSL is required to sign release artifacts")
signature = Path(f"{source}.sig")
if signature.is_dir() and not signature.is_symlink():
raise ReleaseSigningError(f"signature path is a directory: {signature}")
signature.unlink(missing_ok=True)
temporary = signature.with_name(f".{signature.name}.{uuid4().hex}.tmp")
try:
_sign(openssl, source, temporary, private)
_verify(openssl, source, temporary, public)
temporary.chmod(0o644)
temporary.replace(signature)
except BaseException:
temporary.unlink(missing_ok=True)
raise
return signature
def _regular_file(path: Path, label: str) -> Path:
value = path.expanduser()
if value.is_symlink() or not value.is_file():
raise ReleaseSigningError(f"{label} is missing or not a regular file: {value}")
return value.resolve()
def _sign(openssl: str, artifact: Path, signature: Path, private_key: Path) -> None:
_run_openssl(
[
openssl,
"dgst",
"-sha256",
"-sign",
str(private_key),
"-out",
str(signature),
str(artifact),
],
"sign the release artifact",
)
def _verify(openssl: str, artifact: Path, signature: Path, public_key: Path) -> None:
_run_openssl(
[
openssl,
"dgst",
"-sha256",
"-verify",
str(public_key),
"-signature",
str(signature),
str(artifact),
],
"verify the release artifact signature",
)
def _run_openssl(command: list[str], action: str) -> None:
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ReleaseSigningError(f"OpenSSL could not {action}: {exc}") from exc
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or "unknown error"
raise ReleaseSigningError(f"OpenSSL could not {action}: {detail}")
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("artifact", type=Path)
parser.add_argument("--signing-key", type=Path, required=True)
parser.add_argument("--public-key", type=Path, required=True)
arguments = parser.parse_args(argv)
try:
signature = sign_release_artifact(
arguments.artifact,
signing_key=arguments.signing_key,
public_key=arguments.public_key,
)
except (OSError, ReleaseSigningError) as exc:
print(f"Could not sign release artifact: {exc}", file=sys.stderr)
return 1
print(signature)
return 0
if __name__ == "__main__":
raise SystemExit(main())