Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a38fc60f2 | |||
| 61700df17e | |||
| ae21f6c64a | |||
| bef8d04d72 | |||
| d1587b785a | |||
| 1e1f76a000 | |||
| 06672589ad | |||
| d20eea6877 | |||
| c49c56bc97 | |||
| e90cb788ba | |||
| 010fe14ab4 | |||
| b344506071 | |||
| 7f6330a617 | |||
| fccac400a4 | |||
| e5a1e1b781 | |||
| f6e2d0923b | |||
| 9d3ec58f0c | |||
| 614d5d96c9 | |||
| 30a08c787c | |||
| b37b5291ae | |||
| d2c66b6316 | |||
| e515080171 |
@@ -1,17 +1,25 @@
|
||||
# PristonTale EU for Linux
|
||||
|
||||
Version 0.5.2 installs, updates, repairs, and launches PristonTale EU through a
|
||||
Version 0.6.5 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.
|
||||
|
||||
## Install
|
||||
|
||||
Keep the archive and checksum file together, then run:
|
||||
Keep the archive, detached signature, checksum, and
|
||||
`launcher-update-public.pem` together. First confirm the public-key fingerprint
|
||||
through an independent trusted channel; the expected DER SHA-256 fingerprint is
|
||||
`3ae6e8dc483b93796b32e3ca270c367d247caf8d047eb796826c8e558a4a9e34`.
|
||||
Then run:
|
||||
|
||||
```text
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-5-2.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-5-2
|
||||
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
|
||||
openssl dgst -sha256 -verify launcher-update-public.pem \
|
||||
-signature pristontale-eu-linux-installer-v0-6-5.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v0-6-5.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-6-5.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-6-5.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-6-5
|
||||
./install.sh
|
||||
```
|
||||
|
||||
@@ -37,11 +45,6 @@ pristontale-eu gui
|
||||
The main launcher button automatically shows **Install**, **Update**, or
|
||||
**Play** according to the managed game's state.
|
||||
|
||||
Version 0.5.0 is the self-update bootstrap. Future signed launcher releases are
|
||||
checked in the background and appear as an **Update Launcher** action. Launcher
|
||||
updates are staged separately from game files and retain the previous version
|
||||
for rollback.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
@@ -49,16 +52,22 @@ pristontale-eu --help Show all commands and options
|
||||
pristontale-eu gui Open the native dashboard
|
||||
pristontale-eu doctor Check required host components
|
||||
pristontale-eu login-diagnostics Check the login route without credentials
|
||||
pristontale-eu login-diagnostics --runtime proton7
|
||||
Compare the direct-Proton prefix read-only
|
||||
pristontale-eu login-trace Retired; refuses without starting the game
|
||||
pristontale-eu update Install or update official files
|
||||
pristontale-eu update --repair Verify and repair every game file
|
||||
pristontale-eu update --dry-run Preview an update
|
||||
pristontale-eu import-game "/path/to/game" Import an existing installation
|
||||
pristontale-eu prepare Build the safe runtime view
|
||||
pristontale-eu play Launch through Gamescope/Proton
|
||||
pristontale-eu play --no-box Launch without Gamescope
|
||||
pristontale-eu play Launch through the managed Proton window
|
||||
pristontale-eu play --no-box Launch without X11 window styling
|
||||
pristontale-eu play --no-world-fix Compatibility alias for the pristine launch
|
||||
pristontale-eu settings Show current settings
|
||||
pristontale-eu settings --init Write default settings
|
||||
pristontale-eu settings --init Create/migrate settings without resetting choices
|
||||
pristontale-eu settings --reset Explicitly restore every launcher default
|
||||
pristontale-eu launcher-update --check Check the signed launcher channel
|
||||
pristontale-eu launcher-update --verify Validate the full release without installing it
|
||||
pristontale-eu launcher-update Install and activate an update
|
||||
pristontale-eu launcher-update --rollback Restore the previous launcher
|
||||
pristontale-eu sync Sync without import discovery
|
||||
@@ -66,7 +75,6 @@ pristontale-eu wine-update Run the legacy WPF updater
|
||||
pristontale-eu fetch Fetch the publisher launcher
|
||||
pristontale-eu setup Prepare its Wine environment
|
||||
pristontale-eu cache-compatibility PATH Cache a verified compatibility pair
|
||||
pristontale-eu cache-runtime-patch PATH Cache a verified runtime patch
|
||||
```
|
||||
|
||||
Use `./install.sh --dry-run` to preview dependency installation or
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
cd0314c9fba1779dac5bbe24190c174941dbed89c371b68808fefa37de7750bf pristontale-eu-linux-installer-v0-5-2.tar.gz
|
||||
BIN
dist/pristontale-eu-linux-installer-v0-5-2.zip
vendored
BIN
dist/pristontale-eu-linux-installer-v0-5-2.zip
vendored
Binary file not shown.
BIN
dist/pristontale-eu-linux-update-v0.5.2.tar.gz
vendored
BIN
dist/pristontale-eu-linux-update-v0.5.2.tar.gz
vendored
Binary file not shown.
@@ -1 +0,0 @@
|
||||
e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002 pristontale-eu-linux-update-v0.5.2.tar.gz
|
||||
11
dist/stable.json
vendored
11
dist/stable.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"archive_sha256": "e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002",
|
||||
"archive_size": 71737,
|
||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.5.2/pristontale-eu-linux-update-v0.5.2.tar.gz",
|
||||
"channel": "stable",
|
||||
"minimum_updater_version": "0.5.0",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.5.2",
|
||||
"published_at": "2026-08-27T19:01:33Z",
|
||||
"schema": 1,
|
||||
"version": "0.5.2"
|
||||
}
|
||||
BIN
dist/stable.json.sig
vendored
BIN
dist/stable.json.sig
vendored
Binary file not shown.
75
install.sh
75
install.sh
@@ -3,12 +3,18 @@
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="${PTLINUX_VERSION:-0.5.2}"
|
||||
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")"
|
||||
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$VERSION" != "$SOURCE_VERSION" ]]
|
||||
then
|
||||
printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
|
||||
BIN_HOME="${HOME}/.local/bin"
|
||||
APPLICATION_HOME="${DATA_HOME}/applications"
|
||||
@@ -133,10 +139,12 @@ RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
|
||||
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||
@@ -190,17 +198,32 @@ then
|
||||
exit 1
|
||||
fi
|
||||
install -d -m 0755 "$STAGING_RELEASE"
|
||||
install -d -m 0755 "${STAGING_RELEASE}/ptlinux" "${STAGING_RELEASE}/ptdiag"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/"
|
||||
install -d -m 0755 \
|
||||
"${STAGING_RELEASE}/ptlinux" \
|
||||
"${STAGING_RELEASE}/ptlinux/native" \
|
||||
"${STAGING_RELEASE}/ptdiag"
|
||||
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py
|
||||
do
|
||||
case "${SOURCE_MODULE##*/}" in
|
||||
desktop_entry.py|launcher_wrapper.py)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/"
|
||||
done
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
||||
"${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py"
|
||||
printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
|
||||
chmod 0644 "${STAGING_RELEASE}/release.json"
|
||||
|
||||
if ! "$PYTHON_BIN" -I -c \
|
||||
if ! "$PYTHON_BIN" -I -B -c \
|
||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli, ptlinux.self_update; raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)' \
|
||||
"$STAGING_RELEASE" "$VERSION"
|
||||
then
|
||||
@@ -277,7 +300,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
|
||||
ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
|
||||
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
|
||||
|
||||
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
"${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||
"$APP_ROOT" \
|
||||
"$INSTALL_DIRECTORY" \
|
||||
@@ -285,7 +308,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||
"$ICON_PATH"
|
||||
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||
"${BIN_HOME}/pristontale-eu" \
|
||||
"$ICON_PATH" \
|
||||
@@ -301,8 +324,44 @@ then
|
||||
update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
printf '%s\n' "Application installed."
|
||||
printf '%s\n' "Launcher files installed."
|
||||
DOCTOR_READY=1
|
||||
if (( RUN_DOCTOR == 1 ))
|
||||
then
|
||||
"${BIN_HOME}/pristontale-eu" doctor || true
|
||||
if ! "${BIN_HOME}/pristontale-eu" doctor
|
||||
then
|
||||
DOCTOR_READY=0
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "Host readiness check skipped."
|
||||
fi
|
||||
|
||||
printf '%s\n' "Start PristonTale EU with:"
|
||||
printf ' %q gui\n' "${BIN_HOME}/pristontale-eu"
|
||||
case ":${PATH:-}:" in
|
||||
*":${BIN_HOME}:"*)
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' \
|
||||
"Note: ${BIN_HOME} is not in PATH for this shell." \
|
||||
"The application-menu shortcut already uses the full path."
|
||||
if [[ "${SHELL##*/}" == "fish" ]]
|
||||
then
|
||||
printf '%s\n' "Enable the short command in Fish with:"
|
||||
printf ' fish_add_path %q\n' "$BIN_HOME"
|
||||
else
|
||||
printf '%s\n' "Enable the short command in this Bash/Zsh session with:"
|
||||
printf ' export PATH=%q:"$PATH"\n' "$BIN_HOME"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( DOCTOR_READY == 0 ))
|
||||
then
|
||||
printf '%s\n' \
|
||||
"Launcher files were installed, but this host is not ready to run the game." \
|
||||
"Resolve the diagnostics above, then run the launcher command again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "Installation ready."
|
||||
|
||||
@@ -121,6 +121,9 @@ ptlinux_resolve_dependency_packages()
|
||||
arch:openssl)
|
||||
candidates=(openssl)
|
||||
;;
|
||||
arch:umu)
|
||||
candidates=(umu-launcher)
|
||||
;;
|
||||
suse:python)
|
||||
candidates=(python3)
|
||||
;;
|
||||
@@ -210,6 +213,7 @@ ptlinux_has_wine_stack()
|
||||
ptlinux_detect_missing_dependencies()
|
||||
{
|
||||
local python_bin="${PYTHON_BIN:-python3}"
|
||||
local family="${1:-}"
|
||||
PTLINUX_MISSING_DEPENDENCIES=()
|
||||
|
||||
if ! command -v "$python_bin" >/dev/null 2>&1
|
||||
@@ -227,10 +231,6 @@ ptlinux_detect_missing_dependencies()
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(winetricks)
|
||||
fi
|
||||
if ! command -v gamescope >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(gamescope)
|
||||
fi
|
||||
if ! command -v cabextract >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(cabextract)
|
||||
@@ -239,6 +239,10 @@ ptlinux_detect_missing_dependencies()
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(openssl)
|
||||
fi
|
||||
if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(umu)
|
||||
fi
|
||||
}
|
||||
|
||||
ptlinux_select_privilege_command()
|
||||
@@ -344,15 +348,14 @@ ptlinux_install_host_dependencies()
|
||||
local package_line
|
||||
local -a packages=()
|
||||
|
||||
ptlinux_detect_missing_dependencies
|
||||
ptlinux_read_distribution
|
||||
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
|
||||
ptlinux_detect_missing_dependencies "$family"
|
||||
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
|
||||
then
|
||||
printf '%s\n' "All required host dependencies are already available."
|
||||
return 0
|
||||
fi
|
||||
|
||||
ptlinux_read_distribution
|
||||
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
|
||||
if [[ "$family" == "unknown" ]]
|
||||
then
|
||||
printf 'Unsupported distribution: %s. Re-run with --skip-deps after installing: %s\n' \
|
||||
@@ -374,7 +377,7 @@ ptlinux_install_host_dependencies()
|
||||
printf '%s\n' "Installing missing host dependencies (authentication may be requested)."
|
||||
ptlinux_install_resolved_packages "$family" "${packages[@]}"
|
||||
hash -r
|
||||
ptlinux_detect_missing_dependencies
|
||||
ptlinux_detect_missing_dependencies "$family"
|
||||
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} != 0 ))
|
||||
then
|
||||
printf 'Dependencies remain unavailable after package installation: %s\n' \
|
||||
|
||||
@@ -16,7 +16,7 @@ do
|
||||
then
|
||||
continue
|
||||
fi
|
||||
if "$python_bin" -I -c \
|
||||
if "$python_bin" -I -B -c \
|
||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
|
||||
"${candidate}" >/dev/null 2>&1
|
||||
then
|
||||
@@ -31,11 +31,10 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${PYTHONPATH:-}" ]
|
||||
then
|
||||
export PYTHONPATH="${release_root}:${PYTHONPATH}"
|
||||
else
|
||||
export PYTHONPATH="${release_root}"
|
||||
fi
|
||||
# Never let an unpacked older installer in the caller's current directory
|
||||
# shadow the selected installed release. Children started by the dashboard
|
||||
# inherit the same safe-path policy and exact package root.
|
||||
export PYTHONSAFEPATH=1
|
||||
export PYTHONPATH="${release_root}"
|
||||
|
||||
exec "$python_bin" -m ptlinux "$@"
|
||||
exec "$python_bin" -B -P -m ptlinux "$@"
|
||||
|
||||
@@ -23,6 +23,11 @@ _FAILED_IMPORT_RE = re.compile(
|
||||
r"Failed to import scene:\s*(?P<scene>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MODULE_CODE_SYNC_RE = re.compile(
|
||||
r"Module Code Sync Error\s*\(\[(?P<left>-?\d+)\]\s*!=\s*"
|
||||
r"\[(?P<right>-?\d+)\]\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TIMESTAMP_RE = re.compile(
|
||||
r"^\s*(?:\[)?(?P<timestamp>"
|
||||
r"\d{4}-\d{2}-\d{2}[T ][0-9:.+Z-]+|\d{2}:\d{2}:\d{2}(?:\.\d+)?)"
|
||||
@@ -72,6 +77,11 @@ class NativeLogAnalysis:
|
||||
import_scene_threw: bool = False
|
||||
stage_without_new_stage: bool = False
|
||||
tex_reclaim_count: int = 0
|
||||
module_code_sync_error: bool = False
|
||||
module_code_sync_left: int | None = None
|
||||
module_code_sync_right: int | None = None
|
||||
module_code_sync_delta: int | None = None
|
||||
banned_code_1: bool = False
|
||||
|
||||
def latest_scene(self, name: str) -> SceneImport | None:
|
||||
wanted = _scene_key(name)
|
||||
@@ -95,6 +105,11 @@ class NativeLogAnalysis:
|
||||
"failed_files": [event.to_dict() for event in self.failed_files],
|
||||
"stage_without_new_stage": self.stage_without_new_stage,
|
||||
"tex_reclaim_count": self.tex_reclaim_count,
|
||||
"module_code_sync_error": self.module_code_sync_error,
|
||||
"module_code_sync_left": self.module_code_sync_left,
|
||||
"module_code_sync_right": self.module_code_sync_right,
|
||||
"module_code_sync_delta": self.module_code_sync_delta,
|
||||
"banned_code_1": self.banned_code_1,
|
||||
}
|
||||
for output_key, scene_name in (
|
||||
("whispering_vale", "WhisperingVale"),
|
||||
@@ -243,6 +258,27 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
|
||||
analysis.import_scene_threw = True
|
||||
analysis.events.append(LogEvent("import_scene_threw", line_number, raw, timestamp))
|
||||
|
||||
module_sync = _MODULE_CODE_SYNC_RE.search(raw)
|
||||
if module_sync is not None:
|
||||
left = int(module_sync.group("left"))
|
||||
right = int(module_sync.group("right"))
|
||||
analysis.module_code_sync_error = True
|
||||
analysis.module_code_sync_left = left
|
||||
analysis.module_code_sync_right = right
|
||||
analysis.module_code_sync_delta = abs(left - right)
|
||||
analysis.events.append(
|
||||
LogEvent(
|
||||
"module_code_sync_error",
|
||||
line_number,
|
||||
raw,
|
||||
timestamp,
|
||||
f"{left} != {right}",
|
||||
)
|
||||
)
|
||||
if "banned code 1" in raw.casefold():
|
||||
analysis.banned_code_1 = True
|
||||
analysis.events.append(LogEvent("banned_code_1", line_number, raw, timestamp))
|
||||
|
||||
failed_import = _FAILED_IMPORT_RE.search(raw)
|
||||
if failed_import is not None:
|
||||
value = failed_import.group("scene").strip()
|
||||
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.5.2"
|
||||
__version__ = "0.6.5"
|
||||
|
||||
401
ptlinux/cli.py
401
ptlinux/cli.py
@@ -9,14 +9,23 @@ from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from typing import Sequence
|
||||
|
||||
from ptdiag.logs import (
|
||||
LogFileState,
|
||||
newest_current_native_log,
|
||||
parse_current_run_log,
|
||||
snapshot_native_logs,
|
||||
)
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandError, CommandRunner
|
||||
from .depot import SyncProgress, sync_managed_game
|
||||
from .doctor import inspect_host
|
||||
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
|
||||
from .importer import GameImportResult, discover_existing_game, import_existing_game
|
||||
from .import_state import game_import_requires_verification, mark_game_import_verified
|
||||
from .launcher import (
|
||||
LauncherError,
|
||||
configure_launcher_prefix,
|
||||
@@ -26,19 +35,21 @@ from .launcher import (
|
||||
)
|
||||
from .locking import launcher_operation_lock
|
||||
from .login_diagnostics import inspect_login_environment
|
||||
from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view
|
||||
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
||||
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
prepare_game_runtime,
|
||||
run_game,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings, ensure_settings, load_settings, save_settings
|
||||
from .settings import Settings, ensure_settings, load_settings, reset_settings
|
||||
from .self_update import (
|
||||
check_for_launcher_update,
|
||||
install_launcher_update,
|
||||
rollback_launcher_update,
|
||||
update_check_to_dict,
|
||||
verify_launcher_update_channel,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +78,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=5.0,
|
||||
help="TCP connection timeout in seconds (default: 5)",
|
||||
)
|
||||
login_diagnostics.add_argument(
|
||||
"--runtime",
|
||||
choices=("configured", "auto", "proton7", "proton7-container", "umu"),
|
||||
default="configured",
|
||||
help=(
|
||||
"probe one runtime without changing settings or starting the game "
|
||||
"(default: configured)"
|
||||
),
|
||||
)
|
||||
|
||||
fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher")
|
||||
fetch.add_argument("--dry-run", action="store_true")
|
||||
@@ -122,27 +142,54 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="cache one exact verified Game.exe/game.dll compatibility pair",
|
||||
)
|
||||
compatibility.add_argument("source", type=Path)
|
||||
runtime_patch = subparsers.add_parser(
|
||||
"cache-runtime-patch",
|
||||
help="cache the exact verified V21 texture-reclaim game.dll",
|
||||
)
|
||||
runtime_patch.add_argument("source", type=Path)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
|
||||
prepare.add_argument("--game-dir", type=Path)
|
||||
|
||||
play = subparsers.add_parser("play", help="launch the game in the clean Gamescope box")
|
||||
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
|
||||
play.add_argument("--game-dir", type=Path)
|
||||
play.add_argument("--no-box", action="store_true", help="launch without Gamescope")
|
||||
play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
||||
play.add_argument(
|
||||
"--publisher-dll",
|
||||
"--no-world-fix",
|
||||
action="store_true",
|
||||
dest="publisher_dll",
|
||||
help=(
|
||||
"compatibility option retained for older scripts; publisher code is "
|
||||
"always used without the retired world fix"
|
||||
),
|
||||
)
|
||||
play.add_argument("--dry-run", action="store_true")
|
||||
play.add_argument("--skip-runtime-setup", action="store_true")
|
||||
login_trace = subparsers.add_parser(
|
||||
"login-trace",
|
||||
help="retired unsafe authentication trace (always refuses to launch)",
|
||||
)
|
||||
login_trace.add_argument("--game-dir", type=Path)
|
||||
login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
||||
login_trace.add_argument("--skip-runtime-setup", action="store_true")
|
||||
login_trace.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=DEFAULT_TRACE_TIMEOUT_SECONDS,
|
||||
help="maximum game runtime in seconds (default: 600)",
|
||||
)
|
||||
|
||||
settings = subparsers.add_parser("settings", help="show or initialize settings")
|
||||
settings.add_argument("--init", action="store_true", help="write conservative defaults")
|
||||
settings = subparsers.add_parser("settings", help="show, initialize, or reset settings")
|
||||
settings_action = settings.add_mutually_exclusive_group()
|
||||
settings_action.add_argument(
|
||||
"--init",
|
||||
action="store_true",
|
||||
help="create missing settings and migrate legacy files without resetting choices",
|
||||
)
|
||||
settings_action.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="explicitly replace all launcher settings with defaults",
|
||||
)
|
||||
|
||||
launcher_update = subparsers.add_parser(
|
||||
"launcher-update",
|
||||
help="check, install, or roll back the native Linux launcher",
|
||||
help="check, verify, install, or roll back the native Linux launcher",
|
||||
)
|
||||
launcher_action = launcher_update.add_mutually_exclusive_group()
|
||||
launcher_action.add_argument(
|
||||
@@ -150,6 +197,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
action="store_true",
|
||||
help="authenticate the update manifest without installing it",
|
||||
)
|
||||
launcher_action.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="download and fully validate the published release without installing it",
|
||||
)
|
||||
launcher_action.add_argument(
|
||||
"--rollback",
|
||||
action="store_true",
|
||||
@@ -181,7 +233,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if command == "doctor":
|
||||
return _doctor(arguments.json)
|
||||
if command == "login-diagnostics":
|
||||
return _login_diagnostics(paths, arguments.timeout, arguments.json)
|
||||
return _login_diagnostics(
|
||||
paths,
|
||||
arguments.timeout,
|
||||
arguments.runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "fetch":
|
||||
return _fetch(paths, arguments.dry_run, arguments.json)
|
||||
if command == "setup":
|
||||
@@ -229,10 +286,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
result = cache_compatibility_binaries(paths, arguments.source)
|
||||
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "cache-runtime-patch":
|
||||
result = cache_runtime_patch(paths, arguments.source)
|
||||
_emit({"ok": True, "runtime_patch": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "prepare":
|
||||
return _prepare(paths, arguments.game_dir, arguments.json)
|
||||
if command == "play":
|
||||
@@ -244,18 +297,35 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "login-trace":
|
||||
return _login_trace(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.timeout,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "settings":
|
||||
return _settings(paths, arguments.init, arguments.json)
|
||||
return _settings(paths, arguments.init, arguments.reset, arguments.json)
|
||||
if command == "launcher-update":
|
||||
return _launcher_update(
|
||||
paths,
|
||||
arguments.check,
|
||||
arguments.verify,
|
||||
arguments.rollback,
|
||||
arguments.manifest_url,
|
||||
arguments.public_key,
|
||||
arguments.json,
|
||||
)
|
||||
parser.error(f"unknown command: {command}")
|
||||
except KeyboardInterrupt:
|
||||
message = "Interrupted; PristonTale and its compatibility runtime were stopped."
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": message, "interrupted": True})
|
||||
else:
|
||||
print(f"\n{message}", file=sys.stderr)
|
||||
return 130
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
@@ -287,25 +357,55 @@ def _doctor(as_json: bool) -> int:
|
||||
return 0 if report.ok else 1
|
||||
|
||||
|
||||
def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -> int:
|
||||
def _login_diagnostics(
|
||||
paths: AppPaths,
|
||||
timeout_seconds: float,
|
||||
runtime_choice: str,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
settings = _load_or_default_settings(paths, persist=False)
|
||||
report = inspect_login_environment(
|
||||
paths,
|
||||
settings,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
runner = _runner(paths, "login-diagnostics", False)
|
||||
with _operation_guard(paths, "login-diagnostics", False):
|
||||
report = inspect_login_environment(
|
||||
paths,
|
||||
settings,
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
runtime_override=(
|
||||
None if runtime_choice == "configured" else runtime_choice
|
||||
),
|
||||
)
|
||||
if as_json:
|
||||
_print_json(report.to_dict())
|
||||
else:
|
||||
probe = report.login_server
|
||||
print(f"Launcher: {report.launcher_version} ({report.launcher_module})")
|
||||
print(f"Python: {report.python_executable}")
|
||||
print(f"Host: {report.distribution_id} ({report.kernel_release})")
|
||||
print(
|
||||
"Runtime test: "
|
||||
f"{report.runtime_requested} "
|
||||
f"(configured: {report.runtime_configured}; settings unchanged)"
|
||||
)
|
||||
status = "REACHABLE" if probe.reachable else "UNREACHABLE"
|
||||
latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else ""
|
||||
print(f"Login server: {status} ({probe.host}:{probe.port}{latency})")
|
||||
if probe.error:
|
||||
print(f"Network error: {probe.error}")
|
||||
print("Authentication: not tested; no credentials or game-protocol data were sent")
|
||||
print(f"Login responses: {report.login_response_ownership}")
|
||||
identity_status = (
|
||||
"ready"
|
||||
if report.client_identity_ready is True
|
||||
else "NOT READY"
|
||||
if report.client_identity_ready is False
|
||||
else "not probed"
|
||||
)
|
||||
print(f"Client identity: {identity_status}; raw identity values were not collected")
|
||||
print(f"Privacy: {report.client_identity_privacy}")
|
||||
if not report.login_trace_enabled:
|
||||
print("Login trace: retired (will not launch or instrument the game)")
|
||||
print(f"Publisher test: {report.publisher_comparison_command}")
|
||||
print(f"Game profile: {report.game_profile}")
|
||||
print(f"Game.exe: {report.game_exe}")
|
||||
if report.game_exe_sha256:
|
||||
@@ -313,10 +413,24 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -
|
||||
print(f"game.dll: {report.game_dll}")
|
||||
if report.game_dll_sha256:
|
||||
print(f"DLL SHA-256: {report.game_dll_sha256}")
|
||||
print(
|
||||
"Managed source: "
|
||||
+ ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS")
|
||||
)
|
||||
if report.depot_build is not None:
|
||||
print(
|
||||
f"Depot: build {report.depot_build} "
|
||||
f"({report.depot_version or 'unknown version'})"
|
||||
)
|
||||
if report.runtime_kind:
|
||||
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
|
||||
else:
|
||||
print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})")
|
||||
if report.proton_sync_effective is not None:
|
||||
print(
|
||||
"Proton sync: "
|
||||
f"{report.proton_sync_configured} -> {report.proton_sync_effective}"
|
||||
)
|
||||
prefix_status = "ready" if report.prefix_ready else "not ready"
|
||||
directx_status = "ready" if report.directx_ready else "not ready"
|
||||
print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}")
|
||||
@@ -414,13 +528,22 @@ def _native_update(
|
||||
runner,
|
||||
announce=not as_json,
|
||||
)
|
||||
verify_import = game_import is not None or game_import_requires_verification(paths)
|
||||
_print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
|
||||
result = sync_managed_game(
|
||||
paths,
|
||||
repair=repair,
|
||||
repair=repair or verify_import,
|
||||
quarantine_unmanaged=verify_import,
|
||||
dry_run=dry_run,
|
||||
progress=None if as_json else _print_sync_progress,
|
||||
)
|
||||
if verify_import and not dry_run:
|
||||
mark_game_import_verified(
|
||||
paths,
|
||||
build=result.build,
|
||||
files_quarantined=result.files_quarantined,
|
||||
quarantine_directory=result.quarantine_directory,
|
||||
)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
|
||||
@@ -469,6 +592,23 @@ def _wine_update(
|
||||
announce=not as_json,
|
||||
)
|
||||
updater = launch_official_updater(paths, tools, runner)
|
||||
native_verification = None
|
||||
verify_import = not dry_run and (
|
||||
game_import is not None or game_import_requires_verification(paths)
|
||||
)
|
||||
if verify_import:
|
||||
native_verification = sync_managed_game(
|
||||
paths,
|
||||
repair=True,
|
||||
quarantine_unmanaged=True,
|
||||
progress=None if as_json else _print_sync_progress,
|
||||
)
|
||||
mark_game_import_verified(
|
||||
paths,
|
||||
build=native_verification.build,
|
||||
files_quarantined=native_verification.files_quarantined,
|
||||
quarantine_directory=native_verification.quarantine_directory,
|
||||
)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
overlay = asdict(prepare_runtime_view(paths, paths.source_game))
|
||||
@@ -477,6 +617,9 @@ def _wine_update(
|
||||
"dry_run": dry_run,
|
||||
"game_import": asdict(game_import) if game_import else None,
|
||||
"updater": asdict(updater),
|
||||
"native_verification": (
|
||||
asdict(native_verification) if native_verification is not None else None
|
||||
),
|
||||
"runtime_view": overlay,
|
||||
}
|
||||
_emit(value, as_json)
|
||||
@@ -544,6 +687,11 @@ def _maybe_import_game(
|
||||
|
||||
|
||||
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update"
|
||||
)
|
||||
settings = ensure_settings(paths)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
@@ -559,6 +707,30 @@ def _play(
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
with _operation_guard(paths, "play", dry_run):
|
||||
return _play_locked(
|
||||
paths,
|
||||
game_dir,
|
||||
no_box,
|
||||
dry_run,
|
||||
skip_runtime_setup,
|
||||
as_json,
|
||||
)
|
||||
|
||||
|
||||
def _play_locked(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update before playing"
|
||||
)
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
overlay = None
|
||||
@@ -573,13 +745,40 @@ def _play(
|
||||
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
||||
setup_results = prepare_game_runtime(paths, selection, runner)
|
||||
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
native_log_directory = paths.game_save / "logs"
|
||||
native_log_baseline = snapshot_native_logs(
|
||||
native_log_directory,
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
game_started_ns = time.time_ns()
|
||||
try:
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
game_started_ns,
|
||||
)
|
||||
if current_game_log is not None and not as_json:
|
||||
_print_interrupted_game_log(current_game_log)
|
||||
raise
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
game_started_ns,
|
||||
)
|
||||
runtime_issues = _runtime_issue_summary(outcome.stderr)
|
||||
if (
|
||||
selection.kind == "proton7-container"
|
||||
and runtime_issues["host_futex_fatal_count"]
|
||||
):
|
||||
runtime_issues["recommended_one_variable_comparison"] = "proton7"
|
||||
_emit(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
@@ -588,16 +787,118 @@ def _play(
|
||||
"runtime_view": asdict(overlay) if overlay else None,
|
||||
"setup_commands": [asdict(result) for result in setup_results],
|
||||
"game": asdict(outcome),
|
||||
"current_game_log": current_game_log,
|
||||
"runtime_issues": runtime_issues,
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0 if outcome.returncode == 0 else 1
|
||||
|
||||
|
||||
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
|
||||
if initialize:
|
||||
save_settings(paths, Settings())
|
||||
settings = ensure_settings(paths) if initialize else load_settings(paths)
|
||||
def _current_game_log(
|
||||
directory: Path,
|
||||
baseline: dict[str, LogFileState],
|
||||
started_ns: int,
|
||||
) -> dict[str, object] | None:
|
||||
state = newest_current_native_log(
|
||||
directory,
|
||||
baseline,
|
||||
started_ns,
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
if state is None:
|
||||
return None
|
||||
analysis = parse_current_run_log(state, baseline)
|
||||
return {
|
||||
"path": str(state.path),
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
state.mtime_ns / 1_000_000_000
|
||||
).astimezone().isoformat(),
|
||||
"size": state.size,
|
||||
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
|
||||
"import_scene_threw": analysis.import_scene_threw,
|
||||
"failed_import_scenes": analysis.failed_import_scenes,
|
||||
"stage_without_new_stage": analysis.stage_without_new_stage,
|
||||
"module_code_sync_error": analysis.module_code_sync_error,
|
||||
"module_code_sync_left": analysis.module_code_sync_left,
|
||||
"module_code_sync_right": analysis.module_code_sync_right,
|
||||
"module_code_sync_delta": analysis.module_code_sync_delta,
|
||||
"banned_code_1": analysis.banned_code_1,
|
||||
"banned_code_1_in_native_log": analysis.banned_code_1,
|
||||
"banned_code_1_observation_scope": (
|
||||
"native-game-log-only; server-provided login-screen text is not "
|
||||
"written to this log"
|
||||
),
|
||||
"stage_without_new_stage_events": [
|
||||
event.to_dict()
|
||||
for event in analysis.events
|
||||
if event.kind == "stage_without_new_stage"
|
||||
],
|
||||
"scene_imports": {
|
||||
name: [scene.to_dict() for scene in imports]
|
||||
for name, imports in sorted(analysis.scenes.items())
|
||||
},
|
||||
"failed_file_count": len(analysis.failed_files),
|
||||
"failed_files": [event.to_dict() for event in analysis.failed_files[:64]],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_issue_summary(stderr: str) -> dict[str, object]:
|
||||
folded = stderr.casefold()
|
||||
futex_message = "the futex facility returned an unexpected error code"
|
||||
wrong_elf_message = "wrong elf class: elfclass64"
|
||||
return {
|
||||
"host_futex_fatal_count": folded.count(futex_message),
|
||||
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
|
||||
"gstreamer_stream_error": "winegstreamer error" in folded,
|
||||
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
|
||||
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
|
||||
}
|
||||
|
||||
|
||||
def _print_interrupted_game_log(report: dict[str, object]) -> None:
|
||||
print(f"\nCurrent game log: {report['path']}", file=sys.stderr)
|
||||
if report["module_code_sync_error"]:
|
||||
print(
|
||||
"Detected a publisher module-integrity mismatch; the game was launched "
|
||||
"with publisher-pristine code in this launcher version.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif report["load_texture_to_material_failed"]:
|
||||
print(
|
||||
"Detected a texture/material failure while loading the game world.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif report["import_scene_threw"] or report["failed_import_scenes"]:
|
||||
print("Detected a game-world scene import failure.", file=sys.stderr)
|
||||
|
||||
|
||||
def _login_trace(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
skip_runtime_setup: bool,
|
||||
timeout_seconds: float,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
# Preserve the command name so old instructions fail safely before touching
|
||||
# the prefix, preparing the runtime view, or starting Game.exe.
|
||||
_ = paths, game_dir, no_box, skip_runtime_setup, timeout_seconds, as_json
|
||||
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)
|
||||
|
||||
|
||||
def _settings(
|
||||
paths: AppPaths,
|
||||
initialize: bool,
|
||||
reset: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
if reset:
|
||||
settings = reset_settings(paths)
|
||||
elif initialize:
|
||||
settings = ensure_settings(paths)
|
||||
else:
|
||||
settings = load_settings(paths)
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -612,6 +913,7 @@ def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
|
||||
def _launcher_update(
|
||||
paths: AppPaths,
|
||||
check_only: bool,
|
||||
verify: bool,
|
||||
rollback: bool,
|
||||
manifest_url: str | None,
|
||||
public_key: Path | None,
|
||||
@@ -640,6 +942,21 @@ def _launcher_update(
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
if verify:
|
||||
_print_operation_status(as_json, 1, 2, "Authenticating the launcher release…")
|
||||
result = verify_launcher_update_channel(
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key,
|
||||
)
|
||||
_print_operation_status(as_json, 2, 2, "Published launcher release verified.")
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"launcher_update_verification": asdict(result),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
_print_operation_status(as_json, 1, 2, "Verifying the signed launcher release…")
|
||||
result = install_launcher_update(
|
||||
paths,
|
||||
@@ -659,14 +976,18 @@ def _launcher_update(
|
||||
|
||||
def _source_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
_settings: Settings,
|
||||
argument: Path | None,
|
||||
) -> Path:
|
||||
"""Select the managed game unless an explicit diagnostic path was supplied.
|
||||
|
||||
The persisted ``game_source`` setting is an import seed only. It must not
|
||||
redirect normal Play, Prepare, or Login Trace operations away from the
|
||||
launcher-managed tree.
|
||||
"""
|
||||
|
||||
if argument is not None:
|
||||
return argument.expanduser().resolve()
|
||||
source_override = settings.source_override
|
||||
if source_override is not None:
|
||||
return source_override
|
||||
return paths.source_game
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping, Sequence, TextIO
|
||||
from typing import Callable, Mapping, Sequence, TextIO
|
||||
|
||||
|
||||
OutputLineFilter = Callable[[str], str | None]
|
||||
MAX_CAPTURE_CHARACTERS = 1024 * 1024
|
||||
SUBPROCESS_ENCODING = "utf-8"
|
||||
SUBPROCESS_DECODE_ERRORS = "replace"
|
||||
|
||||
|
||||
class BoundedTextCapture:
|
||||
"""Retain the newest subprocess output without unbounded memory growth."""
|
||||
|
||||
def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None:
|
||||
if max_characters <= 0:
|
||||
raise ValueError("capture size must be positive")
|
||||
self._max_characters = max_characters
|
||||
self._parts: deque[str] = deque()
|
||||
self._characters = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, value: str) -> None:
|
||||
if not value:
|
||||
return
|
||||
with self._lock:
|
||||
if len(value) >= self._max_characters:
|
||||
self._parts.clear()
|
||||
self._parts.append(value[-self._max_characters:])
|
||||
self._characters = self._max_characters
|
||||
return
|
||||
self._parts.append(value)
|
||||
self._characters += len(value)
|
||||
while self._characters > self._max_characters:
|
||||
overflow = self._characters - self._max_characters
|
||||
oldest = self._parts[0]
|
||||
if len(oldest) <= overflow:
|
||||
self._parts.popleft()
|
||||
self._characters -= len(oldest)
|
||||
else:
|
||||
self._parts[0] = oldest[overflow:]
|
||||
self._characters -= overflow
|
||||
|
||||
def getvalue(self) -> str:
|
||||
with self._lock:
|
||||
return "".join(self._parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,6 +98,7 @@ class CommandRunner:
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
@@ -81,6 +127,7 @@ class CommandRunner:
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
output_line_filter=output_line_filter,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -89,6 +136,8 @@ class CommandRunner:
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=environment,
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
@@ -97,8 +146,8 @@ class CommandRunner:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
stdout=_bounded_text(completed.stdout),
|
||||
stderr=_bounded_text(completed.stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=False,
|
||||
dry_run=False,
|
||||
@@ -107,8 +156,8 @@ class CommandRunner:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None,
|
||||
stdout=_as_text(exc.stdout),
|
||||
stderr=_as_text(exc.stderr),
|
||||
stdout=_bounded_text(_as_text(exc.stdout)),
|
||||
stderr=_bounded_text(_as_text(exc.stderr)),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=True,
|
||||
dry_run=False,
|
||||
@@ -126,31 +175,43 @@ class CommandRunner:
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
output_line_filter: OutputLineFilter | None,
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(environment),
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
stdout = BoundedTextCapture()
|
||||
stderr = BoundedTextCapture()
|
||||
|
||||
def relay(
|
||||
stream: TextIO,
|
||||
capture: list[str],
|
||||
capture: BoundedTextCapture,
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
try:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
if output_line_filter is not None:
|
||||
line = output_line_filter(line)
|
||||
if line is None:
|
||||
continue
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
except (OSError, ValueError):
|
||||
# Shutdown can close a pipe while its relay thread is leaving readline().
|
||||
return
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
@@ -171,17 +232,17 @@ class CommandRunner:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
returncode = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
returncode = _terminate_process_group(process)
|
||||
except BaseException:
|
||||
_terminate_process_group(process)
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
raise
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
stdout="".join(stdout),
|
||||
stderr="".join(stderr),
|
||||
stdout=stdout.getvalue(),
|
||||
stderr=stderr.getvalue(),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=timed_out,
|
||||
dry_run=False,
|
||||
@@ -210,3 +271,53 @@ def _as_text(value: bytes | str | None) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(value: str) -> str:
|
||||
return value[-MAX_CAPTURE_CHARACTERS:]
|
||||
|
||||
|
||||
def _terminate_process_group(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
grace_seconds: float = 5.0,
|
||||
) -> int | None:
|
||||
"""Stop the complete child session, allowing Proton a short clean exit."""
|
||||
|
||||
if process.poll() is not None:
|
||||
return process.returncode
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
return process.poll()
|
||||
|
||||
|
||||
def _finish_stream_relays(
|
||||
process: subprocess.Popen[str],
|
||||
stdout_thread: threading.Thread,
|
||||
stderr_thread: threading.Thread,
|
||||
) -> None:
|
||||
"""Bound relay cleanup so a second interrupt can never be required."""
|
||||
|
||||
stdout_thread.join(timeout=2.0)
|
||||
stderr_thread.join(timeout=2.0)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
stdout_thread.join(timeout=0.25)
|
||||
stderr_thread.join(timeout=0.25)
|
||||
|
||||
172
ptlinux/depot.py
172
ptlinux/depot.py
@@ -13,6 +13,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable, Mapping, Protocol
|
||||
@@ -38,6 +39,38 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
|
||||
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
|
||||
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
|
||||
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
|
||||
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
|
||||
{
|
||||
".asi",
|
||||
".bat",
|
||||
".cmd",
|
||||
".com",
|
||||
".cpl",
|
||||
".desktop",
|
||||
".dll",
|
||||
".drv",
|
||||
".exe",
|
||||
".jar",
|
||||
".js",
|
||||
".lnk",
|
||||
".lua",
|
||||
".msi",
|
||||
".ocx",
|
||||
".ps1",
|
||||
".py",
|
||||
".reg",
|
||||
".scr",
|
||||
".sh",
|
||||
".so",
|
||||
".url",
|
||||
".vbe",
|
||||
".vbs",
|
||||
".wsf",
|
||||
}
|
||||
)
|
||||
ALWAYS_VERIFY_PATHS = frozenset(
|
||||
{"game.exe", "game.dll", "gamese.exe", "gamese.dll"}
|
||||
)
|
||||
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
||||
|
||||
@@ -129,11 +162,21 @@ class DepotSyncResult:
|
||||
bytes_downloaded: int
|
||||
files_deleted: int
|
||||
files_created: int
|
||||
files_quarantined: int
|
||||
quarantine_directory: str | None
|
||||
repair: bool
|
||||
dry_run: bool
|
||||
completed_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuarantineResult:
|
||||
"""Recoverable unmanaged entries removed before authenticating an import."""
|
||||
|
||||
files: int
|
||||
directory: str | None
|
||||
|
||||
|
||||
ProgressCallback = Callable[[SyncProgress], None]
|
||||
|
||||
|
||||
@@ -453,7 +496,11 @@ def plan_update(
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect {file.path}: {exc}") from exc
|
||||
if repair or file.build == snapshot.build:
|
||||
if (
|
||||
repair
|
||||
or file.build == snapshot.build
|
||||
or file.path.casefold() in ALWAYS_VERIFY_PATHS
|
||||
):
|
||||
if _sha1(target).casefold() != file.sha1.casefold():
|
||||
downloads.append(PlannedDownload(file, "hash mismatch"))
|
||||
|
||||
@@ -479,6 +526,7 @@ def sync_managed_game(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
repair: bool = False,
|
||||
quarantine_unmanaged: bool = False,
|
||||
dry_run: bool = False,
|
||||
progress: ProgressCallback | None = None,
|
||||
client: DepotClient | None = None,
|
||||
@@ -489,6 +537,9 @@ def sync_managed_game(
|
||||
depot_client = client or DepotClient()
|
||||
_emit_progress(progress, "manifest", 0, 0, 0, 0, None)
|
||||
snapshot = depot_client.fetch_snapshot()
|
||||
quarantine = QuarantineResult(0, None)
|
||||
if quarantine_unmanaged and not dry_run:
|
||||
quarantine = _quarantine_unmanaged_files(paths, snapshot)
|
||||
plan = plan_update(paths.source_game, snapshot, repair=repair)
|
||||
_emit_progress(
|
||||
progress,
|
||||
@@ -501,7 +552,16 @@ def sync_managed_game(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return _sync_result(snapshot, plan, 0, 0, 0, repair, True)
|
||||
return _sync_result(
|
||||
snapshot,
|
||||
plan,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
repair,
|
||||
True,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
|
||||
root = _prepare_game_root(paths.source_game, create=True)
|
||||
_download_plan(depot_client, snapshot, root, plan, progress)
|
||||
@@ -516,6 +576,7 @@ def sync_managed_game(
|
||||
repair,
|
||||
False,
|
||||
created=created,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
_write_json(paths.native_depot_state, asdict(result))
|
||||
_emit_progress(
|
||||
@@ -530,6 +591,110 @@ def sync_managed_game(
|
||||
return result
|
||||
|
||||
|
||||
def _quarantine_unmanaged_files(
|
||||
paths: AppPaths,
|
||||
snapshot: DepotSnapshot,
|
||||
) -> QuarantineResult:
|
||||
"""Move non-depot import contents aside without following links."""
|
||||
|
||||
root = _prepare_game_root(paths.source_game, create=False)
|
||||
if not root.exists():
|
||||
return QuarantineResult(0, None)
|
||||
allowed = {
|
||||
file.path for file in snapshot.files
|
||||
} | {
|
||||
entry.path for entry in snapshot.create_if_missing
|
||||
}
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
|
||||
def walk_error(error: OSError) -> None:
|
||||
raise DepotError(f"cannot inspect imported game tree: {error}") from error
|
||||
|
||||
for directory, directory_names, file_names in os.walk(
|
||||
root,
|
||||
topdown=True,
|
||||
followlinks=False,
|
||||
onerror=walk_error,
|
||||
):
|
||||
parent = Path(directory)
|
||||
for name in tuple(directory_names):
|
||||
path = parent / name
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
|
||||
if stat.S_ISDIR(status.st_mode):
|
||||
continue
|
||||
directory_names.remove(name)
|
||||
candidates.append((path, path.relative_to(root).as_posix()))
|
||||
for name in file_names:
|
||||
path = parent / name
|
||||
relative = path.relative_to(root).as_posix()
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
candidates.append((path, relative))
|
||||
continue
|
||||
if relative in allowed:
|
||||
continue
|
||||
if _safe_imported_user_file(path, relative):
|
||||
continue
|
||||
candidates.append((path, relative))
|
||||
|
||||
if not candidates:
|
||||
return QuarantineResult(0, None)
|
||||
base = paths.game_import_quarantine.resolve(strict=False)
|
||||
data_root = paths.data.resolve(strict=False)
|
||||
try:
|
||||
base.relative_to(data_root)
|
||||
except ValueError as exc:
|
||||
raise DepotError(f"import quarantine is outside the data root: {base}") from exc
|
||||
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||||
quarantine = base / f"{stamp}-{uuid4().hex}"
|
||||
files_root = quarantine / "files"
|
||||
files_root.mkdir(parents=True, exist_ok=False)
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"complete": False,
|
||||
"created_at": datetime.now().astimezone().isoformat(),
|
||||
"source": str(root),
|
||||
"files": [relative for _path, relative in candidates],
|
||||
}
|
||||
_write_json(quarantine / "quarantine.json", manifest)
|
||||
for source, relative in candidates:
|
||||
destination = files_root.joinpath(*PurePosixPath(relative).parts)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
source.replace(destination)
|
||||
except OSError as exc:
|
||||
raise DepotError(
|
||||
f"could not quarantine imported entry {relative}: {exc}"
|
||||
) from exc
|
||||
manifest["complete"] = True
|
||||
_write_json(quarantine / "quarantine.json", manifest)
|
||||
return QuarantineResult(len(candidates), str(quarantine))
|
||||
|
||||
|
||||
def _safe_imported_user_file(path: Path, relative: str) -> bool:
|
||||
parts = PurePosixPath(relative).parts
|
||||
if not parts or parts[0].casefold() != "save":
|
||||
return False
|
||||
if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES:
|
||||
return False
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
prefix = handle.read(4)
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc
|
||||
return not (
|
||||
prefix.startswith(b"MZ")
|
||||
or prefix.startswith(b"\x7fELF")
|
||||
or prefix.startswith(b"#!")
|
||||
)
|
||||
|
||||
|
||||
def _download_plan(
|
||||
client: DepotClient,
|
||||
depot_snapshot: DepotSnapshot,
|
||||
@@ -692,6 +857,7 @@ def _sync_result(
|
||||
dry_run: bool,
|
||||
*,
|
||||
created: int = 0,
|
||||
quarantine: QuarantineResult = QuarantineResult(0, None),
|
||||
) -> DepotSyncResult:
|
||||
return DepotSyncResult(
|
||||
region=snapshot.region,
|
||||
@@ -704,6 +870,8 @@ def _sync_result(
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
files_deleted=files_deleted,
|
||||
files_created=created,
|
||||
files_quarantined=quarantine.files,
|
||||
quarantine_directory=quarantine.directory,
|
||||
repair=repair,
|
||||
dry_run=dry_run,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
|
||||
@@ -13,6 +13,11 @@ from typing import Mapping
|
||||
from .launcher import find_wine_tools
|
||||
|
||||
|
||||
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
|
||||
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolStatus:
|
||||
name: str
|
||||
@@ -70,8 +75,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
ToolStatus(
|
||||
"gamescope",
|
||||
shutil.which("gamescope"),
|
||||
True,
|
||||
"borderless X11/Wayland game container",
|
||||
False,
|
||||
"optional manual game-container diagnostics",
|
||||
),
|
||||
ToolStatus(
|
||||
"cabextract",
|
||||
@@ -88,8 +93,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
ToolStatus(
|
||||
"umu-run",
|
||||
shutil.which("umu-run"),
|
||||
False,
|
||||
"portable Proton runtime when Steam Proton 7 is unavailable",
|
||||
distribution in ARCH_FAMILY_DISTRIBUTIONS,
|
||||
"current portable Proton runtime; automatic on Arch-family hosts",
|
||||
),
|
||||
ToolStatus(
|
||||
"vulkaninfo",
|
||||
@@ -98,6 +103,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
"Vulkan diagnostics",
|
||||
),
|
||||
]
|
||||
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
|
||||
warnings: list[str] = []
|
||||
if platform.machine().casefold() not in {"x86_64", "amd64"}:
|
||||
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
|
||||
@@ -107,6 +113,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
warnings.append(
|
||||
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
|
||||
)
|
||||
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
|
||||
required_ok = all(item.path is not None for item in tools if item.required)
|
||||
return DoctorReport(
|
||||
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
|
||||
@@ -147,22 +154,22 @@ def _python_tk_path() -> str | None:
|
||||
def _package_hint(distribution: str) -> str | None:
|
||||
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, Gamescope, "
|
||||
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, "
|
||||
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
|
||||
)
|
||||
if distribution in {"fedora", "nobara"}:
|
||||
return (
|
||||
"Install python3-tkinter, wine, winetricks, gamescope, cabextract, openssl, "
|
||||
"Install python3-tkinter, wine, winetricks, cabextract, openssl, "
|
||||
"and the matching 32-bit Vulkan driver with DNF."
|
||||
)
|
||||
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
|
||||
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return (
|
||||
"Install python, tk, wine, winetricks, gamescope, cabextract, openssl, and "
|
||||
"the matching lib32 Vulkan driver with Pacman."
|
||||
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
|
||||
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
|
||||
)
|
||||
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine, Winetricks, Gamescope, Cabextract, OpenSSL, and "
|
||||
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
|
||||
"32-bit Vulkan support with Zypper."
|
||||
)
|
||||
return None
|
||||
@@ -182,3 +189,55 @@ def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
|
||||
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
|
||||
if architecture.casefold() not in {"x86_64", "amd64"}:
|
||||
return []
|
||||
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return [
|
||||
_optional_tool_status(
|
||||
"lib32-vulkan",
|
||||
"/usr/lib32/libvulkan.so.1",
|
||||
"32-bit Vulkan runtime compatibility layer",
|
||||
),
|
||||
_optional_tool_status(
|
||||
"lib32-gstreamer",
|
||||
"/usr/lib32/gstreamer-1.0",
|
||||
"32-bit gstreamer runtime compatibility modules",
|
||||
),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
|
||||
if distribution not in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return []
|
||||
if architecture.casefold() not in {"x86_64", "amd64"}:
|
||||
return [
|
||||
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
|
||||
]
|
||||
warnings: list[str] = []
|
||||
if not _path_exists("/usr/lib32/libvulkan.so.1"):
|
||||
warnings.append(
|
||||
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
|
||||
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
|
||||
)
|
||||
if not _path_exists("/usr/lib32/gstreamer-1.0"):
|
||||
warnings.append(
|
||||
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
|
||||
"other media does not play. Missing media plugins are not evidence of an "
|
||||
"authentication failure."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
|
||||
if _path_exists(path):
|
||||
return ToolStatus(name, path, required=False, purpose=purpose)
|
||||
return ToolStatus(name, None, required=False, purpose=purpose)
|
||||
|
||||
|
||||
def _path_exists(path: str) -> bool:
|
||||
entry = Path(path)
|
||||
return entry.is_file() or entry.is_dir()
|
||||
|
||||
@@ -23,5 +23,7 @@ def build_gamebox_command(
|
||||
]
|
||||
if settings.borderless:
|
||||
command.append("--borderless")
|
||||
if settings.suppress_unresponsive_warnings:
|
||||
command.append("--suppress-wm-ping")
|
||||
command.extend(("--", *[str(value) for value in game_command]))
|
||||
return command
|
||||
|
||||
@@ -44,6 +44,14 @@ def _select_game_window(
|
||||
return None
|
||||
|
||||
|
||||
def _protocols_without(
|
||||
protocols: Sequence[int],
|
||||
blocked_protocol: int,
|
||||
) -> tuple[int, ...]:
|
||||
"""Remove only the selected atom while preserving every other protocol."""
|
||||
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
|
||||
|
||||
|
||||
class GameboxRuntimeError(RuntimeError):
|
||||
"""Raised when the game command or X11 mapper cannot be started."""
|
||||
|
||||
@@ -127,6 +135,58 @@ class _X11:
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XFlush(self.display)
|
||||
|
||||
def suppress_wm_ping(self, window: int) -> bool:
|
||||
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
||||
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
||||
if ping_atom == 0:
|
||||
return False
|
||||
protocols = self._wm_protocols(window)
|
||||
if protocols is None:
|
||||
return False
|
||||
filtered_protocols = _protocols_without(protocols, ping_atom)
|
||||
if filtered_protocols == protocols:
|
||||
return True
|
||||
if filtered_protocols:
|
||||
protocol_values = (ctypes.c_ulong * len(filtered_protocols))(
|
||||
*filtered_protocols
|
||||
)
|
||||
status = self.library.XSetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
protocol_values,
|
||||
len(filtered_protocols),
|
||||
)
|
||||
else:
|
||||
status = self.library.XSetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.POINTER(ctypes.c_ulong)(),
|
||||
0,
|
||||
)
|
||||
if status == 0:
|
||||
return False
|
||||
self.library.XSync(self.display, 0)
|
||||
return self._wm_protocols(window) == filtered_protocols
|
||||
|
||||
def _wm_protocols(self, window: int) -> tuple[int, ...] | None:
|
||||
protocols = ctypes.POINTER(ctypes.c_ulong)()
|
||||
protocol_count = ctypes.c_int()
|
||||
status = self.library.XGetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.byref(protocols),
|
||||
ctypes.byref(protocol_count),
|
||||
)
|
||||
if status == 0:
|
||||
return None
|
||||
try:
|
||||
return tuple(
|
||||
int(protocols[index]) for index in range(protocol_count.value)
|
||||
)
|
||||
finally:
|
||||
if protocols:
|
||||
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
|
||||
|
||||
def _remove_decorations(self, window: int) -> None:
|
||||
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
|
||||
if atom == 0:
|
||||
@@ -205,8 +265,24 @@ class _X11:
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XChangeProperty.restype = ctypes.c_int
|
||||
self.library.XGetWMProtocols.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
]
|
||||
self.library.XGetWMProtocols.restype = ctypes.c_int
|
||||
self.library.XSetWMProtocols.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XSetWMProtocols.restype = ctypes.c_int
|
||||
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
self.library.XMapRaised.restype = ctypes.c_int
|
||||
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
self.library.XSync.restype = ctypes.c_int
|
||||
self.library.XFlush.argtypes = [ctypes.c_void_p]
|
||||
self.library.XFlush.restype = ctypes.c_int
|
||||
self.library.XFree.argtypes = [ctypes.c_void_p]
|
||||
@@ -215,11 +291,46 @@ class _X11:
|
||||
self.library.XCloseDisplay.restype = ctypes.c_int
|
||||
|
||||
|
||||
def _prepare_game_window(
|
||||
mapper: _X11,
|
||||
window: int,
|
||||
*,
|
||||
borderless: bool,
|
||||
suppress_wm_ping: bool,
|
||||
) -> None:
|
||||
if suppress_wm_ping:
|
||||
try:
|
||||
ping_suppressed = mapper.suppress_wm_ping(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not suppress game-window hang warnings ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
if ping_suppressed:
|
||||
print(
|
||||
"Game-window hang warnings suppressed for this launch.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Warning: could not verify game-window hang-warning "
|
||||
"suppression; continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
mapper.map_window(window, borderless=borderless)
|
||||
|
||||
|
||||
def run_game_window(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
*,
|
||||
borderless: bool = False,
|
||||
suppress_wm_ping: bool = False,
|
||||
) -> int:
|
||||
if not command:
|
||||
raise GameboxRuntimeError("game command cannot be empty")
|
||||
@@ -270,7 +381,12 @@ def run_game_window(
|
||||
and game_window_created_at is not None
|
||||
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
|
||||
):
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
_prepare_game_window(
|
||||
mapper,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
suppress_wm_ping=suppress_wm_ping,
|
||||
)
|
||||
print(
|
||||
f"Game window ready: {game_title or window_title}",
|
||||
file=sys.stderr,
|
||||
@@ -285,7 +401,12 @@ def run_game_window(
|
||||
flush=True,
|
||||
)
|
||||
if game_window is not None:
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
_prepare_game_window(
|
||||
mapper,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
suppress_wm_ping=suppress_wm_ping,
|
||||
)
|
||||
return int(process.wait())
|
||||
finally:
|
||||
mapper.close()
|
||||
@@ -295,6 +416,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--window-title", default=WINDOW_TITLE)
|
||||
parser.add_argument("--borderless", action="store_true")
|
||||
parser.add_argument("--suppress-wm-ping", action="store_true")
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
@@ -305,6 +427,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
command,
|
||||
arguments.window_title,
|
||||
borderless=arguments.borderless,
|
||||
suppress_wm_ping=arguments.suppress_wm_ping,
|
||||
)
|
||||
except (GameboxRuntimeError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
@@ -43,6 +44,7 @@ class GameImportResult:
|
||||
installed_build: int | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
verification_required: bool
|
||||
|
||||
|
||||
def import_existing_game(
|
||||
@@ -87,10 +89,13 @@ def import_existing_game(
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=started_at,
|
||||
verification_required=True,
|
||||
)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
|
||||
committed = False
|
||||
pending_recorded = False
|
||||
try:
|
||||
temporary.mkdir(parents=False, exist_ok=False)
|
||||
runner.run(
|
||||
@@ -111,8 +116,31 @@ def import_existing_game(
|
||||
):
|
||||
cache_compatibility_binaries(paths, source)
|
||||
temporary.replace(destination)
|
||||
committed = True
|
||||
provisional = GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=None,
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_import_manifest(paths, provisional)
|
||||
pending_recorded = True
|
||||
except BaseException:
|
||||
_remove_staging_tree(temporary, destination.parent)
|
||||
if committed and not pending_recorded:
|
||||
_remove_staging_tree(destination, destination.parent)
|
||||
raise
|
||||
|
||||
metadata_destination = None
|
||||
@@ -140,8 +168,9 @@ def import_existing_game(
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_json(paths.game_import_manifest, asdict(result))
|
||||
_write_import_manifest(paths, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -202,22 +231,48 @@ def inventory_game_tree(source_game: Path) -> GameTreeInventory:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
for relative in REQUIRED_GAME_FILES:
|
||||
path = source / relative
|
||||
if not path.is_file():
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError:
|
||||
raise GameImportError(f"game installation is incomplete: missing {relative}")
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {relative}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a non-file: {relative}")
|
||||
|
||||
files = 0
|
||||
total_bytes = 0
|
||||
for root, _directories, filenames in os.walk(source, followlinks=False):
|
||||
|
||||
def walk_error(error: OSError) -> None:
|
||||
raise GameImportError(f"cannot inventory game tree: {error}") from error
|
||||
|
||||
for root, directories, filenames in os.walk(
|
||||
source,
|
||||
followlinks=False,
|
||||
onerror=walk_error,
|
||||
):
|
||||
root_path = Path(root)
|
||||
for directory in directories:
|
||||
path = root_path / directory
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game directory {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISDIR(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
for filename in filenames:
|
||||
path = root_path / filename
|
||||
if path.is_symlink():
|
||||
files += 1
|
||||
continue
|
||||
try:
|
||||
total_bytes += path.stat().st_size
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
total_bytes += status.st_size
|
||||
files += 1
|
||||
return GameTreeInventory(
|
||||
files=files,
|
||||
@@ -327,6 +382,21 @@ def _sha256(path: Path) -> str:
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _write_import_manifest(paths: AppPaths, result: GameImportResult) -> None:
|
||||
_write_json(
|
||||
paths.game_import_manifest,
|
||||
{"schema": 2, **asdict(result)},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .import_state import game_import_requires_verification
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
@@ -24,6 +25,8 @@ class PrimaryAction(str, Enum):
|
||||
INSTALL = "INSTALL"
|
||||
UPDATE = "UPDATE"
|
||||
PLAY = "PLAY"
|
||||
CHECKING = "CHECKING…"
|
||||
LAUNCHER_UPDATE = "UPDATE LAUNCHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -40,8 +43,10 @@ class GameInstallationStatus:
|
||||
def inspect_game_installation(
|
||||
paths: AppPaths,
|
||||
official_build: int | None = None,
|
||||
*,
|
||||
compatibility_ready: bool | None = True,
|
||||
) -> GameInstallationStatus:
|
||||
"""Select Install, Update, or Play without modifying the installation.
|
||||
"""Select the primary launcher action without modifying the installation.
|
||||
|
||||
A completed native sync is the verified source for the local depot build.
|
||||
When the official build cannot be checked, a complete local game remains
|
||||
@@ -54,6 +59,8 @@ def inspect_game_installation(
|
||||
or official_build < 0
|
||||
):
|
||||
raise ValueError("official build must be a non-negative integer")
|
||||
if compatibility_ready is not None and not isinstance(compatibility_ready, bool):
|
||||
raise ValueError("compatibility readiness must be a boolean or null")
|
||||
|
||||
missing_files = tuple(
|
||||
relative
|
||||
@@ -64,10 +71,16 @@ def inspect_game_installation(
|
||||
local_build = _read_verified_local_build(paths.native_depot_state)
|
||||
if not installed:
|
||||
action = PrimaryAction.INSTALL
|
||||
elif game_import_requires_verification(paths):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif official_build is not None and (
|
||||
local_build is None or local_build < official_build
|
||||
):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif compatibility_ready is None:
|
||||
action = PrimaryAction.CHECKING
|
||||
elif not compatibility_ready:
|
||||
action = PrimaryAction.LAUNCHER_UPDATE
|
||||
else:
|
||||
action = PrimaryAction.PLAY
|
||||
return GameInstallationStatus(
|
||||
|
||||
@@ -24,7 +24,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
handle.seek(0)
|
||||
owner = handle.read().strip() or "unknown operation"
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); close the updater and try again"
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
|
||||
601
ptlinux/login_diagnostics.py
Normal file
601
ptlinux/login_diagnostics.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""Credential-free diagnostics for the PristonTale EU login path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandRunner
|
||||
from .identity_probe import (
|
||||
IDENTITY_PROBE_PRIVACY,
|
||||
IdentityProbeResult,
|
||||
probe_client_identity,
|
||||
)
|
||||
from .login_trace import LOGIN_TRACE_DISABLED_REASON, LOGIN_TRACE_ENABLED
|
||||
from .overlay import (
|
||||
COMPATIBILITY_BINARY_HASHES,
|
||||
CURRENT_V21_BINARY_HASHES,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeSelection,
|
||||
detect_distribution_id,
|
||||
native_d3d_ready,
|
||||
resolve_proton_sync_mode,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
LOGIN_SERVER_HOST = "5.135.35.188"
|
||||
LOGIN_SERVER_PORT = 10019
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0
|
||||
VERIFIED_GAME_PROFILES = frozenset(
|
||||
{"v21.75.83", "verified-compatibility"}
|
||||
)
|
||||
SERVER_LOGIN_RESPONSE_OWNERSHIP = (
|
||||
"Banned Code 1 and Banned Code 3 are server-provided login response text; "
|
||||
"neither string exists in the verified V21.75.83 Game.exe/game.dll pair"
|
||||
)
|
||||
CLIENT_IDENTITY_INPUTS = (
|
||||
"Win32_Processor",
|
||||
"Win32_BIOS",
|
||||
"Win32_BaseBoard",
|
||||
"Win32_VideoController",
|
||||
"network-adapter identity",
|
||||
"C: volume information",
|
||||
"computer name",
|
||||
)
|
||||
PUBLISHER_COMPARISON_COMMAND = "pristontale-eu wine-update"
|
||||
RUNTIME_COMPONENT_PRIVACY = (
|
||||
"Only SHA-256 hashes of public runtime executables and DLLs plus expected DLL-"
|
||||
"override flags are reported. Registry contents and machine identifiers are not "
|
||||
"hashed or returned."
|
||||
)
|
||||
PREFIX_COMPONENT_PATHS = (
|
||||
"drive_c/windows/syswow64/d3d9.dll",
|
||||
"drive_c/windows/syswow64/d3dx9_43.dll",
|
||||
"drive_c/windows/syswow64/d3dcompiler_43.dll",
|
||||
"drive_c/windows/syswow64/iphlpapi.dll",
|
||||
"drive_c/windows/syswow64/kernel32.dll",
|
||||
"drive_c/windows/syswow64/ole32.dll",
|
||||
"drive_c/windows/syswow64/oleaut32.dll",
|
||||
"drive_c/windows/syswow64/wbemprox.dll",
|
||||
"drive_c/windows/syswow64/wbem/wbemprox.dll",
|
||||
)
|
||||
|
||||
|
||||
class _Closable(Protocol):
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
LoginConnector = Callable[[tuple[str, int], float], _Closable]
|
||||
|
||||
|
||||
def _create_login_connection(address: tuple[str, int], timeout: float) -> _Closable:
|
||||
return socket.create_connection(address, timeout)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginServerProbe:
|
||||
host: str
|
||||
port: int
|
||||
reachable: bool
|
||||
latency_ms: float | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeComponentFingerprint:
|
||||
privacy: str
|
||||
runtime_wrapper_sha256: str | None
|
||||
proton_script_sha256: str | None
|
||||
proton_version_sha256: str | None
|
||||
proton_toolmanifest_sha256: str | None
|
||||
proton_wine_sha256: str | None
|
||||
proton_wineserver_sha256: str | None
|
||||
prefix_dll_sha256: dict[str, str | None]
|
||||
registry_overrides: dict[str, bool]
|
||||
runtime_marker_matches_selection: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginDiagnosticReport:
|
||||
preflight_ok: bool
|
||||
checked_at: str
|
||||
launcher_version: str
|
||||
launcher_module: str
|
||||
python_executable: str
|
||||
distribution_id: str
|
||||
kernel_release: str
|
||||
login_server: LoginServerProbe
|
||||
authentication_tested: bool
|
||||
login_response_ownership: str
|
||||
client_identity_inputs: tuple[str, ...]
|
||||
client_identity_values_collected: bool
|
||||
client_identity_shape_collected: bool
|
||||
client_identity_ready: bool | None
|
||||
client_identity_privacy: str
|
||||
client_identity_probe: IdentityProbeResult | None
|
||||
login_trace_enabled: bool
|
||||
login_trace_disabled_reason: str | None
|
||||
publisher_comparison_command: str
|
||||
install_directory: str
|
||||
game_exe: str
|
||||
game_exe_sha256: str | None
|
||||
game_dll: str
|
||||
game_dll_sha256: str | None
|
||||
source_game_exe_sha256: str | None
|
||||
source_game_dll_sha256: str | None
|
||||
runtime_matches_managed_source: bool
|
||||
game_profile: str
|
||||
depot_build: int | None
|
||||
depot_version: str | None
|
||||
depot_completed_at: str | None
|
||||
runtime_configured: str
|
||||
runtime_requested: str
|
||||
runtime_override_applied: bool
|
||||
runtime_kind: str | None
|
||||
runtime_path: str | None
|
||||
runtime_error: str | None
|
||||
runtime_components: RuntimeComponentFingerprint | None
|
||||
proton_sync_configured: str
|
||||
proton_sync_effective: str | None
|
||||
prefix: str | None
|
||||
prefix_ready: bool
|
||||
directx_ready: bool
|
||||
latest_game_log: str | None
|
||||
latest_game_log_modified_at: str | None
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def probe_login_server(
|
||||
*,
|
||||
host: str = LOGIN_SERVER_HOST,
|
||||
port: int = LOGIN_SERVER_PORT,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> LoginServerProbe:
|
||||
"""Test only the TCP handshake; never send credentials or protocol data."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("login diagnostic timeout must be greater than zero")
|
||||
started = clock()
|
||||
try:
|
||||
connection = connector((host, port), timeout_seconds)
|
||||
connection.close()
|
||||
except OSError as exc:
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=False,
|
||||
latency_ms=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=True,
|
||||
latency_ms=round((clock() - started) * 1000.0, 1),
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
def inspect_login_environment(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
runner: CommandRunner | None = None,
|
||||
runtime_override: str | None = None,
|
||||
) -> LoginDiagnosticReport:
|
||||
"""Inspect the installed client and its network route without authenticating."""
|
||||
|
||||
probe = probe_login_server(
|
||||
timeout_seconds=timeout_seconds,
|
||||
connector=connector,
|
||||
)
|
||||
runtime_requested = runtime_override or settings.runtime
|
||||
diagnostic_settings = replace(settings, runtime=runtime_requested)
|
||||
selection, runtime_error = _runtime_selection(diagnostic_settings)
|
||||
distribution_id = detect_distribution_id() or "unknown"
|
||||
kernel_release = platform.release()
|
||||
proton_sync_effective = (
|
||||
resolve_proton_sync_mode(
|
||||
settings.proton_sync,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
if selection is not None
|
||||
and selection.kind in {"proton7", "proton7-container"}
|
||||
else None
|
||||
)
|
||||
game_root = _active_game_root(paths)
|
||||
game_exe = game_root / "Game.exe"
|
||||
game_dll = game_root / "game.dll"
|
||||
game_exe_sha256 = _sha256_if_file(game_exe)
|
||||
game_dll_sha256 = _sha256_if_file(game_dll)
|
||||
source_game_exe_sha256 = _sha256_if_file(paths.source_game / "Game.exe")
|
||||
source_game_dll_sha256 = _sha256_if_file(paths.source_game / "game.dll")
|
||||
runtime_matches_managed_source = (
|
||||
game_exe_sha256 is not None
|
||||
and game_dll_sha256 is not None
|
||||
and game_exe_sha256 == source_game_exe_sha256
|
||||
and game_dll_sha256 == source_game_dll_sha256
|
||||
)
|
||||
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
||||
depot_state = _read_json_object(paths.native_depot_state)
|
||||
prefix = _runtime_prefix(paths, selection)
|
||||
prefix_ready = _prefix_ready(prefix)
|
||||
directx_ready = native_d3d_ready(prefix) if prefix is not None else False
|
||||
runtime_components = (
|
||||
inspect_runtime_components(paths, selection, prefix)
|
||||
if selection is not None and prefix is not None
|
||||
else None
|
||||
)
|
||||
identity_probe = (
|
||||
probe_client_identity(paths, selection, runner)
|
||||
if selection is not None and prefix_ready and runner is not None
|
||||
else None
|
||||
)
|
||||
latest_log = latest_native_game_log(paths)
|
||||
warnings = _diagnostic_warnings(
|
||||
probe,
|
||||
game_profile,
|
||||
runtime_error,
|
||||
prefix_ready,
|
||||
directx_ready,
|
||||
latest_log,
|
||||
runtime_matches_managed_source,
|
||||
identity_probe,
|
||||
)
|
||||
identity_preflight_ok = (
|
||||
True
|
||||
if runner is None
|
||||
else identity_probe is not None and identity_probe.ready is True
|
||||
)
|
||||
return LoginDiagnosticReport(
|
||||
preflight_ok=(
|
||||
probe.reachable
|
||||
and game_profile in VERIFIED_GAME_PROFILES
|
||||
and runtime_matches_managed_source
|
||||
and selection is not None
|
||||
and prefix_ready
|
||||
and directx_ready
|
||||
and identity_preflight_ok
|
||||
),
|
||||
checked_at=datetime.now().astimezone().isoformat(),
|
||||
launcher_version=__version__,
|
||||
launcher_module=str(Path(__file__).resolve()),
|
||||
python_executable=sys.executable,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
login_server=probe,
|
||||
authentication_tested=False,
|
||||
login_response_ownership=SERVER_LOGIN_RESPONSE_OWNERSHIP,
|
||||
client_identity_inputs=CLIENT_IDENTITY_INPUTS,
|
||||
client_identity_values_collected=False,
|
||||
client_identity_shape_collected=(
|
||||
identity_probe is not None and identity_probe.report is not None
|
||||
),
|
||||
client_identity_ready=(
|
||||
identity_probe.ready if identity_probe is not None else None
|
||||
),
|
||||
client_identity_privacy=IDENTITY_PROBE_PRIVACY,
|
||||
client_identity_probe=identity_probe,
|
||||
login_trace_enabled=LOGIN_TRACE_ENABLED,
|
||||
login_trace_disabled_reason=(
|
||||
None if LOGIN_TRACE_ENABLED else LOGIN_TRACE_DISABLED_REASON
|
||||
),
|
||||
publisher_comparison_command=PUBLISHER_COMPARISON_COMMAND,
|
||||
install_directory=str(paths.data),
|
||||
game_exe=str(game_exe),
|
||||
game_exe_sha256=game_exe_sha256,
|
||||
game_dll=str(game_dll),
|
||||
game_dll_sha256=game_dll_sha256,
|
||||
source_game_exe_sha256=source_game_exe_sha256,
|
||||
source_game_dll_sha256=source_game_dll_sha256,
|
||||
runtime_matches_managed_source=runtime_matches_managed_source,
|
||||
game_profile=game_profile,
|
||||
depot_build=_integer_or_none(depot_state.get("build")),
|
||||
depot_version=_string_or_none(depot_state.get("version")),
|
||||
depot_completed_at=_string_or_none(depot_state.get("completed_at")),
|
||||
runtime_configured=settings.runtime,
|
||||
runtime_requested=runtime_requested,
|
||||
runtime_override_applied=runtime_override is not None,
|
||||
runtime_kind=selection.kind if selection is not None else None,
|
||||
runtime_path=_runtime_path(selection),
|
||||
runtime_error=runtime_error,
|
||||
runtime_components=runtime_components,
|
||||
proton_sync_configured=settings.proton_sync,
|
||||
proton_sync_effective=proton_sync_effective,
|
||||
prefix=str(prefix) if prefix is not None else None,
|
||||
prefix_ready=prefix_ready,
|
||||
directx_ready=directx_ready,
|
||||
latest_game_log=str(latest_log) if latest_log is not None else None,
|
||||
latest_game_log_modified_at=_modified_at(latest_log),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def inspect_runtime_components(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
prefix: Path,
|
||||
) -> RuntimeComponentFingerprint:
|
||||
"""Fingerprint non-private runtime components for side-by-side reports."""
|
||||
|
||||
proton_script_sha256: str | None = None
|
||||
proton_version_sha256: str | None = None
|
||||
proton_toolmanifest_sha256: str | None = None
|
||||
proton_wine_sha256: str | None = None
|
||||
proton_wineserver_sha256: str | None = None
|
||||
if selection.proton_path:
|
||||
proton = Path(selection.proton_path)
|
||||
proton_script_sha256 = _sha256_if_file(proton)
|
||||
proton_version_sha256 = _sha256_if_file(proton.parent / "version")
|
||||
proton_toolmanifest_sha256 = _sha256_if_file(
|
||||
proton.parent / "toolmanifest.vdf"
|
||||
)
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
wineserver = directory / "wineserver"
|
||||
if wine.is_file() and wineserver.is_file():
|
||||
proton_wine_sha256 = _sha256_if_file(wine)
|
||||
proton_wineserver_sha256 = _sha256_if_file(wineserver)
|
||||
break
|
||||
prefix_dll_sha256 = {
|
||||
relative: _sha256_if_file(prefix / relative)
|
||||
for relative in PREFIX_COMPONENT_PATHS
|
||||
}
|
||||
runtime_marker = _read_json_object(paths.game_runtime_marker)
|
||||
marker_matches = (
|
||||
runtime_marker.get("runtime") == asdict(selection)
|
||||
if runtime_marker
|
||||
else None
|
||||
)
|
||||
return RuntimeComponentFingerprint(
|
||||
privacy=RUNTIME_COMPONENT_PRIVACY,
|
||||
runtime_wrapper_sha256=_sha256_if_file(Path(selection.executable)),
|
||||
proton_script_sha256=proton_script_sha256,
|
||||
proton_version_sha256=proton_version_sha256,
|
||||
proton_toolmanifest_sha256=proton_toolmanifest_sha256,
|
||||
proton_wine_sha256=proton_wine_sha256,
|
||||
proton_wineserver_sha256=proton_wineserver_sha256,
|
||||
prefix_dll_sha256=prefix_dll_sha256,
|
||||
registry_overrides=_runtime_registry_override_flags(prefix),
|
||||
runtime_marker_matches_selection=marker_matches,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_registry_override_flags(prefix: Path) -> dict[str, bool]:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
registry = ""
|
||||
return {
|
||||
"d3d9_native_builtin": '"d3d9"="native,builtin"' in registry,
|
||||
"d3dx9_43_native": _native_override_present(registry, "d3dx9_43"),
|
||||
"d3dcompiler_43_native": _native_override_present(
|
||||
registry,
|
||||
"d3dcompiler_43",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _native_override_present(registry: str, name: str) -> bool:
|
||||
return any(
|
||||
entry in registry
|
||||
for entry in (
|
||||
f'"{name}"="native,builtin"',
|
||||
f'"{name}"="native"',
|
||||
f'"*{name}"="native"',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def classify_game_profile(
|
||||
game_exe_sha256: str | None,
|
||||
game_dll_sha256: str | None,
|
||||
) -> str:
|
||||
if game_exe_sha256 is None or game_dll_sha256 is None:
|
||||
return "missing"
|
||||
pair = {"Game.exe": game_exe_sha256, "game.dll": game_dll_sha256}
|
||||
if pair == CURRENT_V21_BINARY_HASHES:
|
||||
return "v21.75.83"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == RETIRED_POST_AUTH_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-post-auth-disk-patch"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == PATCHED_V21_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-texture-reclaim"
|
||||
if pair == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-compatibility"
|
||||
known_game_exe_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
||||
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
}
|
||||
known_game_dll_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["game.dll"],
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
}
|
||||
if game_exe_sha256 in known_game_exe_hashes or game_dll_sha256 in known_game_dll_hashes:
|
||||
return "mismatched"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def latest_native_game_log(paths: AppPaths) -> Path | None:
|
||||
"""Return the newest native log from the active managed source or runtime view."""
|
||||
|
||||
directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs")
|
||||
logs: dict[Path, Path] = {}
|
||||
for directory in directories:
|
||||
try:
|
||||
candidates = directory.glob("log_*.txt")
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
logs[candidate.resolve()] = candidate
|
||||
except OSError:
|
||||
continue
|
||||
if not logs:
|
||||
return None
|
||||
try:
|
||||
return max(logs.values(), key=lambda item: item.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _active_game_root(paths: AppPaths) -> Path:
|
||||
if paths.runtime_view.is_dir():
|
||||
return paths.runtime_view
|
||||
return paths.source_game
|
||||
|
||||
|
||||
def _runtime_selection(settings: Settings) -> tuple[RuntimeSelection | None, str | None]:
|
||||
try:
|
||||
return select_runtime(settings), None
|
||||
except RuntimeError as exc:
|
||||
return None, str(exc)
|
||||
|
||||
|
||||
def _runtime_path(selection: RuntimeSelection | None) -> str | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return selection.proton_path or selection.executable
|
||||
|
||||
|
||||
def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return (
|
||||
paths.proton_prefix
|
||||
if selection.kind in {"proton7", "proton7-container"}
|
||||
else paths.umu_prefix
|
||||
)
|
||||
|
||||
|
||||
def _prefix_ready(prefix: Path | None) -> bool:
|
||||
if prefix is None:
|
||||
return False
|
||||
return (prefix / "user.reg").is_file() and (
|
||||
prefix / "drive_c/windows/system32/cmd.exe"
|
||||
).is_file()
|
||||
|
||||
|
||||
def _diagnostic_warnings(
|
||||
probe: LoginServerProbe,
|
||||
game_profile: str,
|
||||
runtime_error: str | None,
|
||||
prefix_ready: bool,
|
||||
directx_ready: bool,
|
||||
latest_log: Path | None,
|
||||
runtime_matches_managed_source: bool,
|
||||
identity_probe: IdentityProbeResult | None,
|
||||
) -> tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
if not probe.reachable:
|
||||
warnings.append(
|
||||
"The publisher login port is unreachable from this Linux host; check the "
|
||||
"network, firewall, VPN, or server status."
|
||||
)
|
||||
if game_profile == "missing":
|
||||
warnings.append("Game.exe or game.dll is missing; install or update the game first.")
|
||||
elif game_profile == "mismatched":
|
||||
warnings.append(
|
||||
"Game.exe and game.dll are from different verified releases; run update --repair."
|
||||
)
|
||||
elif game_profile == "unknown":
|
||||
warnings.append("Game.exe/game.dll do not match a launcher-verified client profile.")
|
||||
elif game_profile == "retired-texture-reclaim":
|
||||
warnings.append(
|
||||
"A retired experimental game.dll patch is active and can stall login; "
|
||||
"run prepare or launch the game once to restore the publisher DLL."
|
||||
)
|
||||
elif game_profile == "retired-post-auth-disk-patch":
|
||||
warnings.append(
|
||||
"The retired 0.5.8 disk-patched game.dll is active and can stall login; "
|
||||
"run prepare or launch with the current launcher to restore the publisher DLL."
|
||||
)
|
||||
if not runtime_matches_managed_source:
|
||||
warnings.append(
|
||||
"The runtime Game.exe/game.dll pair does not match the managed updater "
|
||||
"source; run prepare after update --repair before testing login again."
|
||||
)
|
||||
if runtime_error is not None:
|
||||
warnings.append(runtime_error)
|
||||
elif not prefix_ready:
|
||||
warnings.append("The selected game prefix is not initialized; run play once to prepare it.")
|
||||
elif not directx_ready:
|
||||
warnings.append("The native DirectX helper DLLs are not ready; run play to repair them.")
|
||||
if latest_log is None:
|
||||
warnings.append("No native game log was found yet.")
|
||||
if identity_probe is not None:
|
||||
warnings.extend(identity_probe.warnings)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _integer_or_none(value: object) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
return _sha256(path) if path.is_file() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _modified_at(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
|
||||
except OSError:
|
||||
return None
|
||||
20
ptlinux/login_trace.py
Normal file
20
ptlinux/login_trace.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Retired login instrumentation retained only for command compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0
|
||||
LOGIN_TRACE_ENABLED = False
|
||||
LOGIN_TRACE_DISABLED_REASON = (
|
||||
"login-trace is retired and will not start the game: it enabled Wine relay "
|
||||
"instrumentation during authentication, while the current client performs "
|
||||
"anti-debug and module-integrity checks. Use login-diagnostics for a "
|
||||
"credential-free check, then use wine-update for one controlled comparison "
|
||||
"through the unmodified publisher launcher."
|
||||
)
|
||||
|
||||
|
||||
def run_login_trace(*_args: object, **_kwargs: object) -> None:
|
||||
"""Refuse to reactivate the retired relay-based authentication trace."""
|
||||
|
||||
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)
|
||||
@@ -153,7 +153,13 @@ def fetch_news(
|
||||
_write_cache(cache_path, articles)
|
||||
return articles
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_cache(cache_path)
|
||||
return read_cached_news(cache_path)
|
||||
|
||||
|
||||
def read_cached_news(cache_path: Path) -> tuple[NewsArticle, ...]:
|
||||
"""Read the last valid news feed without performing network I/O."""
|
||||
|
||||
return _read_cache(cache_path)
|
||||
|
||||
|
||||
def fetch_latest_broadcast(
|
||||
@@ -179,7 +185,13 @@ def fetch_latest_broadcast(
|
||||
_write_broadcast_cache(cache_path, broadcast)
|
||||
return broadcast
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_broadcast_cache(cache_path)
|
||||
return read_cached_broadcast(cache_path)
|
||||
|
||||
|
||||
def read_cached_broadcast(cache_path: Path) -> Broadcast | None:
|
||||
"""Read the last valid broadcast without performing network I/O."""
|
||||
|
||||
return _read_broadcast_cache(cache_path)
|
||||
|
||||
|
||||
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
|
||||
@@ -288,7 +300,7 @@ def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
|
||||
except (OSError, TypeError, json.JSONDecodeError):
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
||||
return ()
|
||||
|
||||
|
||||
@@ -309,7 +321,7 @@ def _read_broadcast_cache(path: Path) -> Broadcast | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return Broadcast(**value) if isinstance(value, dict) else None
|
||||
except (OSError, TypeError, json.JSONDecodeError):
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from ptdiag.assbin import (
|
||||
)
|
||||
|
||||
from .paths import AppPaths
|
||||
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
|
||||
|
||||
|
||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||
@@ -42,6 +41,10 @@ CURRENT_V21_BINARY_HASHES = {
|
||||
PATCHED_V21_GAME_DLL_SHA256 = (
|
||||
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
)
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
|
||||
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
||||
)
|
||||
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
|
||||
|
||||
|
||||
class OverlayError(RuntimeError):
|
||||
@@ -72,11 +75,59 @@ class CompatibilityCacheResult:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePatchCacheResult:
|
||||
source: str
|
||||
destination: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
class RuntimeCompatibility:
|
||||
"""Read-only compatibility result for the managed game currently on disk."""
|
||||
|
||||
compatible: bool
|
||||
source_game: str
|
||||
source_scene_sha256: str | None
|
||||
scene_action: str | None
|
||||
binary_action: str | None
|
||||
reason: str | None
|
||||
|
||||
|
||||
def inspect_runtime_compatibility(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
) -> RuntimeCompatibility:
|
||||
"""Check whether ``prepare_runtime_view`` can recognize the installed release.
|
||||
|
||||
This preflight is intentionally read-only: it never normalizes a scene,
|
||||
builds the verified runtime DLL, prepares save data, or replaces the runtime
|
||||
view. The normal preparation path still performs every authoritative check
|
||||
immediately before launch.
|
||||
"""
|
||||
|
||||
requested_source = source_game.expanduser()
|
||||
source_scene_sha256 = None
|
||||
scene_action = None
|
||||
binary_action = None
|
||||
try:
|
||||
source = requested_source.resolve(strict=True)
|
||||
_validate_game_tree(source)
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
source_scene_sha256 = inspection.file_sha256
|
||||
scene_action = _scene_action(inspection)
|
||||
binary_action = _inspect_runtime_binary_action(paths, source)
|
||||
except (OSError, ValueError, OverlayError) as exc:
|
||||
reason = str(exc).strip() or type(exc).__name__
|
||||
return RuntimeCompatibility(
|
||||
compatible=False,
|
||||
source_game=str(requested_source.resolve(strict=False)),
|
||||
source_scene_sha256=source_scene_sha256,
|
||||
scene_action=scene_action,
|
||||
binary_action=binary_action,
|
||||
reason=reason,
|
||||
)
|
||||
return RuntimeCompatibility(
|
||||
compatible=True,
|
||||
source_game=str(source),
|
||||
source_scene_sha256=source_scene_sha256,
|
||||
scene_action=scene_action,
|
||||
binary_action=binary_action,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
|
||||
def prepare_runtime_view(
|
||||
@@ -91,17 +142,36 @@ def prepare_runtime_view(
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
action = _scene_action(inspection)
|
||||
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
||||
game_save = _prepare_persistent_save(paths, source)
|
||||
(
|
||||
binary_action,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
source_binary_hashes,
|
||||
) = _runtime_game_binary_profile(
|
||||
paths,
|
||||
source,
|
||||
)
|
||||
reused = _reuse_runtime_view(
|
||||
paths,
|
||||
source,
|
||||
inspection,
|
||||
action,
|
||||
game_save,
|
||||
binary_action,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
source_binary_hashes,
|
||||
)
|
||||
if reused is not None:
|
||||
return reused
|
||||
|
||||
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
||||
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
|
||||
links_created = 0
|
||||
try:
|
||||
build.mkdir(parents=True, exist_ok=False)
|
||||
top_level_exceptions = {"game"}
|
||||
top_level_exceptions = {"game", "save"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
@@ -113,6 +183,8 @@ def prepare_runtime_view(
|
||||
build,
|
||||
except_names=top_level_exceptions,
|
||||
)
|
||||
(build / "save").symlink_to(game_save, target_is_directory=True)
|
||||
links_created += 1
|
||||
if game_exe_source != source / "Game.exe":
|
||||
(build / "Game.exe").symlink_to(game_exe_source)
|
||||
links_created += 1
|
||||
@@ -165,7 +237,13 @@ def prepare_runtime_view(
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
links_created=links_created,
|
||||
)
|
||||
_write_json(paths.runtime_manifest, asdict(result))
|
||||
_write_json(
|
||||
paths.runtime_manifest,
|
||||
{
|
||||
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
|
||||
**asdict(result),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -202,50 +280,43 @@ def cache_compatibility_binaries(
|
||||
return result
|
||||
|
||||
|
||||
def cache_runtime_patch(
|
||||
paths: AppPaths,
|
||||
source_dll: Path,
|
||||
) -> RuntimePatchCacheResult:
|
||||
source = source_dll.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch does not match the verified V21 profile")
|
||||
target = paths.runtime_patch_dll
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_file() and _sha256(target) == PATCHED_V21_GAME_DLL_SHA256:
|
||||
return _runtime_patch_result(paths, source)
|
||||
raise OverlayError(f"runtime patch cache has an unknown profile: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
if _sha256(temporary) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("copied runtime patch failed hash verification")
|
||||
temporary.replace(target)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
result = _runtime_patch_result(paths, source)
|
||||
_write_json(paths.runtime_patch_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def select_binary_action(
|
||||
cache_hashes: dict[str, str] | None,
|
||||
source_hashes: dict[str, str],
|
||||
runtime_patch_hash: str | None = None,
|
||||
) -> str:
|
||||
if runtime_patch_hash is not None:
|
||||
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch cache has an unknown binary profile")
|
||||
if cache_hashes is not None:
|
||||
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError("compatibility cache has an unknown binary profile")
|
||||
if source_hashes == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-source-pair"
|
||||
if runtime_patch_hash is not None:
|
||||
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
||||
raise OverlayError("V21 runtime patch does not match the installed client profile")
|
||||
return "verified-v21-texture-reclaim"
|
||||
if source_hashes == CURRENT_V21_BINARY_HASHES:
|
||||
return "verified-v21-publisher-pair"
|
||||
game_exe_sha256 = source_hashes.get("Game.exe")
|
||||
game_dll_sha256 = source_hashes.get("game.dll")
|
||||
if game_dll_sha256 in {
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
}:
|
||||
raise OverlayError(
|
||||
"a retired experimental game.dll is installed; run update --repair "
|
||||
"before launching"
|
||||
)
|
||||
known_game_exe_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
||||
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
}
|
||||
known_game_dll_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["game.dll"],
|
||||
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
}
|
||||
if (
|
||||
game_exe_sha256 in known_game_exe_hashes
|
||||
or game_dll_sha256 in known_game_dll_hashes
|
||||
):
|
||||
raise OverlayError(
|
||||
"Game.exe and game.dll are from different verified releases; run "
|
||||
"update --repair before launching"
|
||||
)
|
||||
return "upstream-pair"
|
||||
|
||||
|
||||
@@ -349,26 +420,287 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _runtime_game_binary_sources(paths: AppPaths, source: Path) -> tuple[str, Path, Path]:
|
||||
def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
|
||||
"""Migrate game-created settings and logs before replacing the runtime view."""
|
||||
|
||||
target = paths.game_save
|
||||
if target.is_symlink():
|
||||
raise OverlayError(f"persistent game save must not be a symbolic link: {target}")
|
||||
if target.exists():
|
||||
if not target.is_dir():
|
||||
raise OverlayError(f"persistent game save is not a directory: {target}")
|
||||
_reject_save_symlinks(target)
|
||||
return target
|
||||
|
||||
candidate = None
|
||||
for path in (paths.runtime_view / "save", source / "save"):
|
||||
if path.is_dir():
|
||||
candidate = path.resolve(strict=True)
|
||||
break
|
||||
if candidate is not None:
|
||||
selected = candidate
|
||||
allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True))
|
||||
if not any(
|
||||
selected == root or selected.is_relative_to(root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
raise OverlayError(f"refusing to migrate game save outside managed roots: {selected}")
|
||||
_reject_save_symlinks(selected)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}"
|
||||
try:
|
||||
if candidate is None:
|
||||
staging.mkdir(parents=False, exist_ok=False)
|
||||
else:
|
||||
shutil.copytree(candidate, staging, symlinks=True)
|
||||
staging.replace(target)
|
||||
except BaseException:
|
||||
_remove_runtime_tree(staging)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def _reject_save_symlinks(root: Path) -> None:
|
||||
for directory, directory_names, file_names in os.walk(root, followlinks=False):
|
||||
parent = Path(directory)
|
||||
for name in (*directory_names, *file_names):
|
||||
path = parent / name
|
||||
if path.is_symlink():
|
||||
raise OverlayError(f"persistent game save contains a symbolic link: {path}")
|
||||
|
||||
|
||||
def _reuse_runtime_view(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
inspection: SceneInspection,
|
||||
scene_action: str,
|
||||
game_save: Path,
|
||||
binary_action: str,
|
||||
game_exe_source: Path,
|
||||
game_dll_source: Path,
|
||||
source_binary_hashes: Mapping[str, str],
|
||||
) -> OverlayResult | None:
|
||||
result = _read_overlay_result(paths.runtime_manifest)
|
||||
if result is None:
|
||||
return None
|
||||
if result.source_game != str(source):
|
||||
return None
|
||||
if result.runtime_view != str(paths.runtime_view):
|
||||
return None
|
||||
if result.source_scene_sha256 != inspection.file_sha256:
|
||||
return None
|
||||
if result.scene_action != scene_action or result.binary_action != binary_action:
|
||||
return None
|
||||
if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
|
||||
return None
|
||||
if result.game_dll_sha256 != source_binary_hashes.get("game.dll"):
|
||||
return None
|
||||
if not _runtime_layout_ready(
|
||||
paths,
|
||||
source,
|
||||
game_save,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
result.links_created,
|
||||
):
|
||||
return None
|
||||
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
|
||||
try:
|
||||
if _sha256(runtime_scene) != result.runtime_scene_sha256:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _runtime_layout_ready(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
game_save: Path,
|
||||
game_exe_source: Path,
|
||||
game_dll_source: Path,
|
||||
expected_links: int,
|
||||
) -> bool:
|
||||
runtime = paths.runtime_view
|
||||
if runtime.is_symlink() or not runtime.is_dir():
|
||||
return False
|
||||
top_level_exceptions = {"game", "save"}
|
||||
top_level_special = {"game", "save"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
):
|
||||
if binary_source != source / name:
|
||||
top_level_exceptions.add(name)
|
||||
top_level_special.add(name)
|
||||
top_level_ready, top_level_links = _linked_level_ready(
|
||||
source,
|
||||
runtime,
|
||||
except_names=top_level_exceptions,
|
||||
special_names=top_level_special,
|
||||
)
|
||||
if not top_level_ready:
|
||||
return False
|
||||
if not _symlink_points_to(runtime / "save", game_save):
|
||||
return False
|
||||
if game_exe_source != source / "Game.exe" and not _symlink_points_to(
|
||||
runtime / "Game.exe",
|
||||
game_exe_source,
|
||||
):
|
||||
return False
|
||||
if game_dll_source != source / "game.dll" and not _symlink_points_to(
|
||||
runtime / "game.dll",
|
||||
game_dll_source,
|
||||
):
|
||||
return False
|
||||
|
||||
game_source = source / "game"
|
||||
game_target = runtime / "game"
|
||||
if game_target.is_symlink() or not game_target.is_dir():
|
||||
return False
|
||||
game_ready, game_links = _linked_level_ready(
|
||||
game_source,
|
||||
game_target,
|
||||
except_names={"maps"},
|
||||
special_names={"maps"},
|
||||
)
|
||||
if not game_ready:
|
||||
return False
|
||||
|
||||
maps_source = game_source / "maps"
|
||||
maps_target = game_target / "maps"
|
||||
if maps_target.is_symlink() or not maps_target.is_dir():
|
||||
return False
|
||||
maps_ready, maps_links = _linked_level_ready(
|
||||
maps_source,
|
||||
maps_target,
|
||||
except_names={"WhisperingVale.rovug"},
|
||||
special_names={"WhisperingVale.rovug"},
|
||||
)
|
||||
if not maps_ready:
|
||||
return False
|
||||
runtime_scene = maps_target / "WhisperingVale.rovug"
|
||||
if runtime_scene.is_symlink() or not runtime_scene.is_file():
|
||||
return False
|
||||
actual_links = top_level_links + game_links + maps_links + 1
|
||||
if game_exe_source != source / "Game.exe":
|
||||
actual_links += 1
|
||||
if game_dll_source != source / "game.dll":
|
||||
actual_links += 1
|
||||
return actual_links == expected_links
|
||||
|
||||
|
||||
def _linked_level_ready(
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
except_names: set[str],
|
||||
special_names: set[str],
|
||||
) -> tuple[bool, int]:
|
||||
try:
|
||||
source_children = {
|
||||
child.name: child
|
||||
for child in source.iterdir()
|
||||
if child.name not in except_names
|
||||
}
|
||||
target_names = {child.name for child in target.iterdir()}
|
||||
except OSError:
|
||||
return False, 0
|
||||
if target_names != set(source_children) | special_names:
|
||||
return False, 0
|
||||
for name, child in source_children.items():
|
||||
if not _symlink_points_to(target / name, child):
|
||||
return False, 0
|
||||
return True, len(source_children)
|
||||
|
||||
|
||||
def _symlink_points_to(link: Path, expected_target: Path) -> bool:
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return link.resolve(strict=True) == expected_target.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_overlay_result(path: Path) -> OverlayResult | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("configuration_version") != RUNTIME_VIEW_CONFIGURATION_VERSION:
|
||||
return None
|
||||
string_fields = (
|
||||
"source_game",
|
||||
"runtime_view",
|
||||
"source_scene_sha256",
|
||||
"runtime_scene_sha256",
|
||||
"scene_action",
|
||||
"binary_action",
|
||||
"game_exe_sha256",
|
||||
"game_dll_sha256",
|
||||
"created_at",
|
||||
)
|
||||
if not all(isinstance(value.get(name), str) for name in string_fields):
|
||||
return None
|
||||
links_created = value.get("links_created")
|
||||
if isinstance(links_created, bool) or not isinstance(links_created, int):
|
||||
return None
|
||||
if links_created < 0:
|
||||
return None
|
||||
return OverlayResult(
|
||||
source_game=value["source_game"],
|
||||
runtime_view=value["runtime_view"],
|
||||
source_scene_sha256=value["source_scene_sha256"],
|
||||
runtime_scene_sha256=value["runtime_scene_sha256"],
|
||||
scene_action=value["scene_action"],
|
||||
binary_action=value["binary_action"],
|
||||
game_exe_sha256=value["game_exe_sha256"],
|
||||
game_dll_sha256=value["game_dll_sha256"],
|
||||
created_at=value["created_at"],
|
||||
links_created=links_created,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_game_binary_sources(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> tuple[str, Path, Path]:
|
||||
action, game_exe, game_dll, _ = _runtime_game_binary_profile(paths, source)
|
||||
return action, game_exe, game_dll
|
||||
|
||||
|
||||
def _runtime_game_binary_profile(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> tuple[str, Path, Path, dict[str, str]]:
|
||||
cache = paths.compatibility_binaries
|
||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
patch = paths.runtime_patch_dll
|
||||
if source_hashes == CURRENT_V21_BINARY_HASHES and not patch.exists():
|
||||
try:
|
||||
result = build_texture_reclaim_patch(source / "game.dll", patch)
|
||||
except TextureReclaimPatchError as exc:
|
||||
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
|
||||
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
|
||||
patch_hash = _sha256(patch) if patch.is_file() else None
|
||||
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
|
||||
action = select_binary_action(cache_hashes, source_hashes)
|
||||
return (
|
||||
action,
|
||||
source / "Game.exe",
|
||||
patch if action == "verified-v21-texture-reclaim" else source / "game.dll",
|
||||
source / "game.dll",
|
||||
source_hashes,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_runtime_binary_action(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> str:
|
||||
"""Mirror publisher binary selection without writing any runtime cache."""
|
||||
|
||||
cache = paths.compatibility_binaries
|
||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
return select_binary_action(cache_hashes, source_hashes)
|
||||
|
||||
|
||||
def _binary_hashes(directory: Path) -> dict[str, str]:
|
||||
return {
|
||||
name: _sha256(directory / name)
|
||||
@@ -387,15 +719,6 @@ def _compatibility_result(paths: AppPaths, source: Path) -> CompatibilityCacheRe
|
||||
)
|
||||
|
||||
|
||||
def _runtime_patch_result(paths: AppPaths, source: Path) -> RuntimePatchCacheResult:
|
||||
return RuntimePatchCacheResult(
|
||||
source=str(source),
|
||||
destination=str(paths.runtime_patch_dll),
|
||||
game_dll_sha256=PATCHED_V21_GAME_DLL_SHA256,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _remove_runtime_tree(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@@ -20,7 +20,13 @@ def _xdg_path(
|
||||
fallback: Path,
|
||||
) -> Path:
|
||||
value = environment.get(variable)
|
||||
return Path(value).expanduser() if value else fallback
|
||||
if not value:
|
||||
return fallback
|
||||
path = Path(value).expanduser()
|
||||
# The XDG Base Directory specification requires absolute paths. Ignoring an
|
||||
# invalid relative value keeps settings anchored to HOME instead of making
|
||||
# their location depend on the launcher's current working directory.
|
||||
return path if path.is_absolute() else fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,6 +97,12 @@ class AppPaths:
|
||||
def launcher_update_lock(self) -> Path:
|
||||
return self.state / "launcher-update.lock"
|
||||
|
||||
@property
|
||||
def launcher_update_check_cache(self) -> Path:
|
||||
"""Display-only cache of the last authenticated update check."""
|
||||
|
||||
return self.cache / "launcher-update-check.json"
|
||||
|
||||
def launcher_update_archive(self, version: str) -> Path:
|
||||
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
|
||||
|
||||
@@ -118,6 +130,12 @@ class AppPaths:
|
||||
def launcher_operation_lock(self) -> Path:
|
||||
return self.state / "launcher-operation.lock"
|
||||
|
||||
@property
|
||||
def game_runtime_marker(self) -> Path:
|
||||
"""Verified Proton/D3D preparation state for the game prefix."""
|
||||
|
||||
return self.state / "game-runtime.json"
|
||||
|
||||
@property
|
||||
def source_game(self) -> Path:
|
||||
return self.launcher_prefix / GAME_RELATIVE_PATH
|
||||
@@ -126,6 +144,12 @@ class AppPaths:
|
||||
def game_import_manifest(self) -> Path:
|
||||
return self.state / "game-import.json"
|
||||
|
||||
@property
|
||||
def game_import_quarantine(self) -> Path:
|
||||
"""Recoverable files excluded while authenticating an imported game."""
|
||||
|
||||
return self.data / "import-quarantine"
|
||||
|
||||
@property
|
||||
def native_depot_state(self) -> Path:
|
||||
"""Last completed native depot synchronization."""
|
||||
@@ -162,6 +186,16 @@ class AppPaths:
|
||||
def runtime_manifest(self) -> Path:
|
||||
return self.state / "runtime-view.json"
|
||||
|
||||
@property
|
||||
def game_user_data(self) -> Path:
|
||||
"""Writable game-created data kept outside the replaceable runtime view."""
|
||||
|
||||
return self.data / "user-data"
|
||||
|
||||
@property
|
||||
def game_save(self) -> Path:
|
||||
return self.game_user_data / "save"
|
||||
|
||||
@property
|
||||
def compatibility_binaries(self) -> Path:
|
||||
return self.data / "compatibility-binaries"
|
||||
@@ -172,16 +206,26 @@ class AppPaths:
|
||||
|
||||
@property
|
||||
def runtime_patch_directory(self) -> Path:
|
||||
return self.data / "runtime-patches" / "v21-texture-reclaim"
|
||||
"""Retired 0.5.8 disk-patch cache; never selected for play."""
|
||||
|
||||
return self.data / "runtime-patches" / "v21-post-auth-reclaim"
|
||||
|
||||
@property
|
||||
def runtime_patch_dll(self) -> Path:
|
||||
"""Retired 0.5.8 DLL retained only so diagnostics can identify it."""
|
||||
|
||||
return self.runtime_patch_directory / "game.dll"
|
||||
|
||||
@property
|
||||
def runtime_patch_manifest(self) -> Path:
|
||||
return self.state / "runtime-patch.json"
|
||||
|
||||
@property
|
||||
def retired_runtime_patch_dll(self) -> Path:
|
||||
"""The pre-0.5.7 all-session patch, retained only for diagnostics."""
|
||||
|
||||
return self.data / "runtime-patches" / "v21-texture-reclaim" / "game.dll"
|
||||
|
||||
@property
|
||||
def compat_data(self) -> Path:
|
||||
return self.data / "game-compat"
|
||||
@@ -206,6 +250,10 @@ class AppPaths:
|
||||
def settings_file(self) -> Path:
|
||||
return self.config / "settings.json"
|
||||
|
||||
@property
|
||||
def settings_lock(self) -> Path:
|
||||
return self.config / "settings.lock"
|
||||
|
||||
def create_base_directories(self) -> None:
|
||||
for path in (
|
||||
self.data,
|
||||
|
||||
@@ -4,14 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .gamebox import build_gamebox_command
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
@@ -25,6 +27,12 @@ WINETRICKS_CODEPAGE_PROBE = (
|
||||
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
|
||||
)
|
||||
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
|
||||
RUNTIME_DEFAULT_SYNC = "runtime-default"
|
||||
CACHYOS_MARKER = "cachyos"
|
||||
CURRENT_UMU_DISTRIBUTIONS = frozenset(
|
||||
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
|
||||
)
|
||||
GAME_RUNTIME_CONFIGURATION_VERSION = 1
|
||||
|
||||
|
||||
class RuntimeErrorPT(RuntimeError):
|
||||
@@ -42,23 +50,79 @@ class RuntimeSelection:
|
||||
def select_runtime(
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> RuntimeSelection:
|
||||
env = os.environ if environment is None else environment
|
||||
if settings.proton_path:
|
||||
umu = shutil.which("umu-run", path=env.get("PATH"))
|
||||
current_umu_host = prefers_current_umu_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
auto_container = (
|
||||
settings.runtime == "auto"
|
||||
and bool(umu)
|
||||
and not current_umu_host
|
||||
)
|
||||
if settings.runtime == "umu":
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT("UMU was requested but umu-run was not found")
|
||||
if settings.proton_path and settings.runtime in {
|
||||
"auto",
|
||||
"proton7",
|
||||
"proton7-container",
|
||||
}:
|
||||
proton = Path(settings.proton_path).expanduser().resolve()
|
||||
if not proton.is_file():
|
||||
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
|
||||
steam_root = _steam_root_for_proton(proton)
|
||||
if settings.runtime == "proton7-container":
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
if auto_container:
|
||||
assert umu is not None
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime in {"auto", "proton7"}:
|
||||
# Current UMU avoids several rolling-distribution container/media failures and
|
||||
# remains the broad Arch-family compatibility default. Direct Proton 7 is also
|
||||
# known to authenticate on CachyOS, so this is a compatibility policy rather
|
||||
# than an assertion that Proton 7 itself causes server-side Code 1/Code 3.
|
||||
if settings.runtime == "auto" and current_umu_host and umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
if settings.runtime in {"auto", "proton7", "proton7-container"}:
|
||||
proton_result = find_steam_proton7(env)
|
||||
if proton_result is not None:
|
||||
proton, steam_root = proton_result
|
||||
if settings.runtime == "proton7-container" or auto_container:
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime == "proton7":
|
||||
if settings.runtime in {"proton7", "proton7-container"}:
|
||||
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
||||
if settings.runtime in {"auto", "umu"}:
|
||||
umu = shutil.which("umu-run")
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT(
|
||||
@@ -117,9 +181,11 @@ def prepare_game_runtime(
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
)
|
||||
elif selection.kind == "proton7":
|
||||
elif selection.kind in {"proton7", "proton7-container"}:
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
if not runner.dry_run and _game_runtime_ready(paths, selection):
|
||||
return results
|
||||
if not runner.dry_run:
|
||||
paths.compat_data.mkdir(parents=True, exist_ok=True)
|
||||
proton = Path(selection.proton_path)
|
||||
@@ -198,11 +264,13 @@ def prepare_game_runtime(
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.state / "game-runtime.json",
|
||||
paths.game_runtime_marker,
|
||||
{
|
||||
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"native_d3d43": True,
|
||||
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
|
||||
"proton_identity": _proton_identity(selection),
|
||||
"runtime": asdict(selection),
|
||||
},
|
||||
)
|
||||
@@ -216,6 +284,10 @@ def run_game(
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
use_gamebox: bool = True,
|
||||
timeout_seconds: float = 12 * 60 * 60,
|
||||
check: bool = True,
|
||||
extra_environment: Mapping[str, str] | None = None,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
) -> CommandOutcome:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
@@ -226,6 +298,19 @@ def run_game(
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
environment = _umu_environment(paths)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
selection.executable,
|
||||
str(paths.runtime_view / "Game.exe"),
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
environment = _umu_proton7_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
else:
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
@@ -238,9 +323,15 @@ def run_game(
|
||||
WINDOWS_GAME_EXE,
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
environment = _proton_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
if use_gamebox:
|
||||
game_command = build_gamebox_command(game_command, settings)
|
||||
if extra_environment:
|
||||
environment.update(extra_environment)
|
||||
environment["PYTHONPATH"] = _runtime_python_path(
|
||||
os.environ.get("PYTHONPATH"),
|
||||
)
|
||||
@@ -248,8 +339,83 @@ def run_game(
|
||||
game_command,
|
||||
cwd=paths.runtime_view,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=12 * 60 * 60,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
stream_output=True,
|
||||
output_line_filter=output_line_filter,
|
||||
)
|
||||
|
||||
|
||||
def run_proton_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> CommandOutcome:
|
||||
"""Run a maintenance command in the pinned Proton 7 prefix."""
|
||||
|
||||
if selection.kind not in {"proton7", "proton7-container"}:
|
||||
raise RuntimeErrorPT("the login trace requires the pinned Proton 7 runtime")
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
return runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
*arguments,
|
||||
],
|
||||
env_overrides=_proton_environment(paths, selection),
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def run_runtime_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> CommandOutcome:
|
||||
"""Run a credential-free helper inside the selected game's Wine prefix."""
|
||||
|
||||
if not arguments:
|
||||
raise ValueError("runtime-prefix command arguments cannot be empty")
|
||||
if selection.kind == "umu":
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_environment(paths)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_proton7_environment(paths, selection)
|
||||
elif selection.kind == "proton7":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
*arguments,
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
else:
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
return runner.run(
|
||||
command,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
@@ -271,13 +437,42 @@ def _umu_environment(paths: AppPaths) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _umu_proton7_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
"""Run the retained Proton 7 build inside its Steam Runtime container."""
|
||||
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
environment = {
|
||||
# UMU expects the Proton compatibility-data root and manages its pfx child.
|
||||
# Pointing it at proton_prefix would create a nested pfx -> . compatibility
|
||||
# symlink; compat_data preserves the exact prefix prepared by Proton 7.
|
||||
"WINEPREFIX": str(paths.compat_data),
|
||||
"GAMEID": "umu-pristontale-eu",
|
||||
"STORE": "none",
|
||||
"PROTONPATH": str(Path(selection.proton_path).parent),
|
||||
"PROTON_VERB": "waitforexitandrun",
|
||||
"PROTONFIXES_DISABLE": "1",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
"WINEDEBUG": "-all",
|
||||
}
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def _proton_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
if not selection.steam_root:
|
||||
raise RuntimeErrorPT("Steam root is missing for Proton")
|
||||
return {
|
||||
environment = {
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
|
||||
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
|
||||
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
|
||||
@@ -288,6 +483,90 @@ def _proton_environment(
|
||||
"PROTON_LOG": "0",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
}
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def detect_distribution_id() -> str:
|
||||
"""Return the freedesktop distribution ID without invoking distro tools."""
|
||||
|
||||
try:
|
||||
value = platform.freedesktop_os_release().get("ID", "")
|
||||
except OSError:
|
||||
return ""
|
||||
return value.strip().casefold()
|
||||
|
||||
|
||||
def is_cachyos_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Identify CachyOS narrowly for runtime compatibility policy."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
kernel = platform.release() if kernel_release is None else kernel_release
|
||||
return (
|
||||
CACHYOS_MARKER in distribution.casefold()
|
||||
or CACHYOS_MARKER in kernel.casefold()
|
||||
)
|
||||
|
||||
|
||||
def prefers_current_umu_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Use current UMU automatically on rolling Arch-family hosts."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
return (
|
||||
distribution.strip().casefold() in CURRENT_UMU_DISTRIBUTIONS
|
||||
or is_cachyos_host(
|
||||
distribution_id=distribution,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_proton_sync_mode(
|
||||
configured: str,
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the configured mode, applying the narrow CachyOS compatibility fallback."""
|
||||
|
||||
if configured not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {configured}")
|
||||
if configured != "auto":
|
||||
return configured
|
||||
if is_cachyos_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
):
|
||||
return "esync"
|
||||
return RUNTIME_DEFAULT_SYNC
|
||||
|
||||
|
||||
def _proton_sync_environment(synchronization: str) -> dict[str, str]:
|
||||
if synchronization == RUNTIME_DEFAULT_SYNC:
|
||||
return {}
|
||||
if synchronization == "esync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "1",
|
||||
"WINEFSYNC": "0",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
if synchronization == "fsync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "0",
|
||||
"WINEFSYNC": "1",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
raise ValueError(f"unsupported resolved Proton synchronization mode: {synchronization}")
|
||||
|
||||
|
||||
def _ensure_proton_game_link(paths: AppPaths) -> None:
|
||||
@@ -348,21 +627,23 @@ def prepare_proton7_winetricks(
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
try:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
content = source.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
|
||||
if WINETRICKS_CODEPAGE_PROBE not in content:
|
||||
probe = WINETRICKS_CODEPAGE_PROBE.encode("utf-8")
|
||||
replacement = WINETRICKS_PROTON7_PROBE.encode("utf-8")
|
||||
if probe not in content:
|
||||
return source
|
||||
if dry_run:
|
||||
return destination
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
patched = content.replace(
|
||||
WINETRICKS_CODEPAGE_PROBE,
|
||||
WINETRICKS_PROTON7_PROBE,
|
||||
probe,
|
||||
replacement,
|
||||
1,
|
||||
)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(patched, encoding="utf-8")
|
||||
temporary.write_bytes(patched)
|
||||
temporary.chmod(source.stat().st_mode & 0o777)
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
@@ -374,6 +655,69 @@ def _proton_prefix_ready(prefix: Path) -> bool:
|
||||
).exists()
|
||||
|
||||
|
||||
def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||
marker = _read_json(paths.game_runtime_marker)
|
||||
identity = _proton_identity(selection)
|
||||
if marker is None or identity is None:
|
||||
return False
|
||||
if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION:
|
||||
return False
|
||||
if marker.get("runtime") != asdict(selection):
|
||||
return False
|
||||
if marker.get("proton_identity") != identity:
|
||||
return False
|
||||
if marker.get("native_d3d43") is not True:
|
||||
return False
|
||||
if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE:
|
||||
return False
|
||||
if not _proton_prefix_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not native_d3d_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not _d3d9_override_ready(paths.proton_prefix):
|
||||
return False
|
||||
return _proton_game_link_ready(paths)
|
||||
|
||||
|
||||
def _d3d9_override_ready(prefix: Path) -> bool:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
return False
|
||||
return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry
|
||||
|
||||
|
||||
def _proton_game_link_ready(paths: AppPaths) -> bool:
|
||||
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None:
|
||||
if not selection.proton_path:
|
||||
return None
|
||||
proton = Path(selection.proton_path)
|
||||
identity_paths = {
|
||||
"proton": proton,
|
||||
"version": proton.parent / "version",
|
||||
"toolmanifest": proton.parent / "toolmanifest.vdf",
|
||||
}
|
||||
identity: dict[str, str] = {}
|
||||
for name, path in identity_paths.items():
|
||||
try:
|
||||
identity[name] = _sha256(path)
|
||||
except OSError:
|
||||
return None
|
||||
return identity
|
||||
|
||||
|
||||
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
@@ -388,3 +732,19 @@ def _write_json(path: Path, value: object) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@@ -45,6 +45,7 @@ UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v"
|
||||
_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z")
|
||||
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
|
||||
UPDATE_CHECK_CACHE_SCHEMA = 1
|
||||
|
||||
|
||||
class LauncherUpdateError(RuntimeError):
|
||||
@@ -84,7 +85,20 @@ class LauncherUpdateResult:
|
||||
previous_release: str | None
|
||||
archive: str | None
|
||||
archive_sha256: str | None
|
||||
restarted_required: bool
|
||||
restart_required: bool
|
||||
state_recorded: bool
|
||||
warning: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdateVerification:
|
||||
current_version: str
|
||||
release_version: str
|
||||
manifest_url: str
|
||||
archive_url: str
|
||||
archive_size: int
|
||||
archive_sha256: str
|
||||
payload_files: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -143,6 +157,89 @@ def check_for_launcher_update(
|
||||
)
|
||||
_verify_manifest_signature(manifest_bytes, signature_bytes, key)
|
||||
manifest = parse_update_manifest(manifest_bytes)
|
||||
return _evaluate_launcher_update(manifest, current_version, url)
|
||||
|
||||
|
||||
def read_cached_launcher_update_check(
|
||||
cache_path: Path,
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> LauncherUpdateCheck | None:
|
||||
"""Read a display-only result from the last authenticated live check."""
|
||||
|
||||
url = manifest_url or configured_manifest_url(environment)
|
||||
key = public_key_path or configured_public_key_path(environment)
|
||||
try:
|
||||
_parse_version(current_version, "current launcher version")
|
||||
_require_https_url(url, "update manifest URL")
|
||||
key_sha256 = _sha256_file(key.expanduser().resolve())
|
||||
value = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (
|
||||
OSError,
|
||||
UnicodeError,
|
||||
json.JSONDecodeError,
|
||||
LauncherUpdateError,
|
||||
RuntimeError,
|
||||
):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("schema") != UPDATE_CHECK_CACHE_SCHEMA:
|
||||
return None
|
||||
if value.get("current_version") != current_version:
|
||||
return None
|
||||
if value.get("manifest_url") != url:
|
||||
return None
|
||||
if value.get("public_key_sha256") != key_sha256:
|
||||
return None
|
||||
manifest_value = value.get("manifest")
|
||||
if not isinstance(manifest_value, dict):
|
||||
return None
|
||||
try:
|
||||
manifest = parse_update_manifest(
|
||||
(json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8")
|
||||
)
|
||||
except (LauncherUpdateError, TypeError, ValueError):
|
||||
return None
|
||||
return _evaluate_launcher_update(manifest, current_version, url)
|
||||
|
||||
|
||||
def cache_launcher_update_check(
|
||||
cache_path: Path,
|
||||
check: LauncherUpdateCheck,
|
||||
*,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Cache an authenticated result for startup display, never for installation."""
|
||||
|
||||
key = public_key_path or configured_public_key_path(environment)
|
||||
try:
|
||||
key_sha256 = _sha256_file(key.expanduser().resolve())
|
||||
_write_json(
|
||||
cache_path,
|
||||
{
|
||||
"schema": UPDATE_CHECK_CACHE_SCHEMA,
|
||||
"checked_at": datetime.now().astimezone().isoformat(),
|
||||
"current_version": check.current_version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"public_key_sha256": key_sha256,
|
||||
"manifest": asdict(check.manifest),
|
||||
},
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evaluate_launcher_update(
|
||||
manifest: UpdateManifest,
|
||||
current_version: str,
|
||||
manifest_url: str,
|
||||
) -> LauncherUpdateCheck:
|
||||
current = _parse_version(current_version, "current launcher version")
|
||||
latest = _parse_version(manifest.version, "manifest version")
|
||||
minimum = _parse_version(
|
||||
@@ -170,7 +267,7 @@ def check_for_launcher_update(
|
||||
available=available,
|
||||
supported=supported,
|
||||
reason=reason,
|
||||
manifest_url=url,
|
||||
manifest_url=manifest_url,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@@ -178,6 +275,7 @@ def check_for_launcher_update(
|
||||
def install_launcher_update(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
@@ -186,6 +284,7 @@ def install_launcher_update(
|
||||
"""Authenticate, stage, validate, and atomically activate one update."""
|
||||
|
||||
check = check_for_launcher_update(
|
||||
current_version=current_version,
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key_path,
|
||||
environment=environment,
|
||||
@@ -200,7 +299,9 @@ def install_launcher_update(
|
||||
previous_release=None,
|
||||
archive=None,
|
||||
archive_sha256=None,
|
||||
restarted_required=False,
|
||||
restart_required=False,
|
||||
state_recorded=False,
|
||||
warning=None,
|
||||
)
|
||||
if not check.supported:
|
||||
raise LauncherUpdateError(check.reason)
|
||||
@@ -215,20 +316,26 @@ def install_launcher_update(
|
||||
)
|
||||
release_directory = _stage_and_install_release(paths, archive, check.manifest)
|
||||
previous = _activate_release(paths, release_directory)
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"updated_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": check.current_version,
|
||||
"to_version": check.manifest.version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"archive_url": check.manifest.archive_url,
|
||||
"archive_sha256": check.manifest.archive_sha256,
|
||||
"release_directory": str(release_directory),
|
||||
"previous_release": str(previous) if previous else None,
|
||||
},
|
||||
)
|
||||
state_warning = None
|
||||
try:
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"updated_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": check.current_version,
|
||||
"to_version": check.manifest.version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"archive_url": check.manifest.archive_url,
|
||||
"archive_sha256": check.manifest.archive_sha256,
|
||||
"release_directory": str(release_directory),
|
||||
"previous_release": str(previous) if previous else None,
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
# Activation is the durable user-facing operation. A diagnostics-only
|
||||
# state record must not strand the dashboard on the old release.
|
||||
state_warning = f"launcher updated, but update history could not be saved: {exc}"
|
||||
return LauncherUpdateResult(
|
||||
changed=True,
|
||||
current_version=check.current_version,
|
||||
@@ -237,7 +344,57 @@ def install_launcher_update(
|
||||
previous_release=str(previous) if previous else None,
|
||||
archive=str(archive),
|
||||
archive_sha256=check.manifest.archive_sha256,
|
||||
restarted_required=True,
|
||||
restart_required=True,
|
||||
state_recorded=state_warning is None,
|
||||
warning=state_warning,
|
||||
)
|
||||
|
||||
|
||||
def verify_launcher_update_channel(
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> LauncherUpdateVerification:
|
||||
"""Authenticate, download, extract, and import a release without activating it."""
|
||||
|
||||
check = check_for_launcher_update(
|
||||
current_version=current_version,
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key_path,
|
||||
environment=environment,
|
||||
timeout_seconds=min(timeout_seconds, 30.0),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary:
|
||||
root = Path(temporary)
|
||||
archive = root / f"{UPDATE_ARCHIVE_PREFIX}{check.manifest.version}.tar.gz"
|
||||
_download_archive(
|
||||
check.manifest,
|
||||
archive,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
staging = root / "release"
|
||||
staging.mkdir(mode=0o700)
|
||||
_extract_release_archive(archive, staging, check.manifest.version)
|
||||
staged_version = _validate_release_directory(staging)
|
||||
if staged_version != check.manifest.version:
|
||||
raise LauncherUpdateError(
|
||||
f"verified launcher version is {staged_version}, "
|
||||
f"expected {check.manifest.version}"
|
||||
)
|
||||
_self_check_release(staging, check.manifest.version)
|
||||
_validate_release_directory(staging)
|
||||
payload_files = sum(1 for item in staging.rglob("*") if item.is_file())
|
||||
return LauncherUpdateVerification(
|
||||
current_version=current_version,
|
||||
release_version=check.manifest.version,
|
||||
manifest_url=check.manifest_url,
|
||||
archive_url=check.manifest.archive_url,
|
||||
archive_size=check.manifest.archive_size,
|
||||
archive_sha256=check.manifest.archive_sha256,
|
||||
payload_files=payload_files,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +470,7 @@ def parse_update_manifest(data: bytes) -> UpdateManifest:
|
||||
f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
|
||||
)
|
||||
published_at = _required_string(value, "published_at")
|
||||
_parse_published_at(published_at)
|
||||
notes_value = value.get("notes_url")
|
||||
if notes_value is not None and not isinstance(notes_value, str):
|
||||
raise LauncherUpdateError("notes_url must be a string or null")
|
||||
@@ -344,6 +502,8 @@ def _fetch_small_file(
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/octet-stream, application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
@@ -354,7 +514,13 @@ def _fetch_small_file(
|
||||
except (URLError, OSError) as exc:
|
||||
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
|
||||
with response:
|
||||
_require_https_url(response.geturl(), f"final {label} URL")
|
||||
final_url = response.geturl()
|
||||
_require_https_url(final_url, f"final {label} URL")
|
||||
if _resource_url(final_url) != _resource_url(url):
|
||||
raise LauncherUpdateError(
|
||||
f"{label} redirected unexpectedly to {final_url}; "
|
||||
"the launcher update channel must be publicly readable"
|
||||
)
|
||||
length = _content_length(response.headers.get("Content-Length"), label)
|
||||
if length is not None and length > maximum_bytes:
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
@@ -363,6 +529,8 @@ def _fetch_small_file(
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
if length is not None and len(data) != length:
|
||||
raise LauncherUpdateError(f"{label} response was incomplete")
|
||||
if not data:
|
||||
raise LauncherUpdateError(f"{label} response was empty")
|
||||
return data
|
||||
|
||||
|
||||
@@ -399,12 +567,14 @@ def _verify_manifest_signature(
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc
|
||||
if result.returncode != 0 or "Verified OK" not in result.stdout:
|
||||
if result.returncode != 0:
|
||||
raise LauncherUpdateError("launcher update manifest signature is invalid")
|
||||
|
||||
|
||||
@@ -501,6 +671,7 @@ def _stage_and_install_release(
|
||||
f"staged launcher version is {staged_version}, expected {manifest.version}"
|
||||
)
|
||||
_self_check_release(staging, manifest.version)
|
||||
_validate_release_directory(staging)
|
||||
staging.chmod(0o755)
|
||||
destination = paths.launcher_releases / manifest.version
|
||||
if destination.exists() or destination.is_symlink():
|
||||
@@ -639,6 +810,7 @@ def _validate_release_directory(release: Path) -> str:
|
||||
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
||||
Path("ptdiag/__init__.py"),
|
||||
Path("ptdiag/assbin.py"),
|
||||
Path("ptdiag/logs.py"),
|
||||
):
|
||||
if not (release / required).is_file():
|
||||
raise LauncherUpdateError(f"launcher release is missing {required}")
|
||||
@@ -654,9 +826,11 @@ def _self_check_release(release: Path, expected_version: str) -> None:
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-c", check, str(release), expected_version],
|
||||
[sys.executable, "-I", "-B", "-c", check, str(release), expected_version],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
@@ -768,6 +942,13 @@ def _signature_url(manifest_url: str) -> str:
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, ""))
|
||||
|
||||
|
||||
def _resource_url(value: str) -> str:
|
||||
parts = urlsplit(value)
|
||||
return urlunsplit(
|
||||
(parts.scheme.casefold(), parts.netloc.casefold(), parts.path, parts.query, "")
|
||||
)
|
||||
|
||||
|
||||
def _require_https_url(value: str, label: str) -> None:
|
||||
parts = urlsplit(value)
|
||||
if (
|
||||
@@ -787,6 +968,18 @@ def _parse_version(value: str, label: str) -> tuple[int, int, int]:
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def _parse_published_at(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise LauncherUpdateError(
|
||||
f"manifest published_at must be an ISO-8601 timestamp: {value!r}"
|
||||
) from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise LauncherUpdateError("manifest published_at must include a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _required_string(value: dict[str, object], key: str) -> str:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, str) or not item:
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from .locking import launcher_operation_lock
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
width: int = 1600
|
||||
@@ -19,9 +26,11 @@ class Settings:
|
||||
refresh_rate: int = 60
|
||||
fullscreen: bool = False
|
||||
borderless: bool = True
|
||||
suppress_unresponsive_warnings: bool = True
|
||||
scaler: str = "fit"
|
||||
filter: str = "linear"
|
||||
runtime: str = "auto"
|
||||
proton_sync: str = "auto"
|
||||
proton_path: str | None = None
|
||||
game_source: str | None = None
|
||||
|
||||
@@ -31,14 +40,98 @@ class Settings:
|
||||
|
||||
|
||||
def load_settings(paths: AppPaths) -> Settings:
|
||||
try:
|
||||
value = json.loads(paths.settings_file.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
value = _read_settings_object(paths.settings_file)
|
||||
if value is None:
|
||||
return Settings()
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
|
||||
return _settings_from_object(value)
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> Settings:
|
||||
"""Atomically save a complete settings snapshot.
|
||||
|
||||
Unknown fields from a newer launcher are retained when the existing file is
|
||||
valid. Callers editing only part of the configuration should use
|
||||
``update_settings`` so an old in-memory snapshot cannot undo another save.
|
||||
"""
|
||||
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-save"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def update_settings(paths: AppPaths, **changes: Any) -> Settings:
|
||||
"""Merge selected settings into the newest on-disk configuration."""
|
||||
|
||||
unknown = set(changes).difference(_SETTINGS_FIELD_NAMES)
|
||||
if unknown:
|
||||
names = ", ".join(sorted(unknown))
|
||||
raise ValueError(f"unsupported launcher setting: {names}")
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-update"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
current = (
|
||||
_settings_from_object(existing)
|
||||
if existing is not None
|
||||
else Settings()
|
||||
)
|
||||
updated = replace(current, **changes)
|
||||
_validate(updated)
|
||||
_write_settings(paths, updated, existing)
|
||||
return updated
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
"""Create settings once and materialize defaults added by newer releases."""
|
||||
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-migrate"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
if existing is None:
|
||||
settings = Settings()
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
settings = _settings_from_object(existing)
|
||||
if _settings_need_migration(existing):
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def reset_settings(paths: AppPaths) -> Settings:
|
||||
"""Replace all launcher settings with the documented defaults."""
|
||||
|
||||
settings = Settings()
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-reset"):
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
|
||||
|
||||
def _read_settings_object(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
try:
|
||||
value = json.loads(_decode_settings(content))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"settings file must contain an object: {paths.settings_file}")
|
||||
raise ValueError(f"settings file must contain an object: {path}")
|
||||
schema = value.get("schema")
|
||||
if schema is not None:
|
||||
if isinstance(schema, bool) or not isinstance(schema, int):
|
||||
raise ValueError(f"settings schema must be an integer: {path}")
|
||||
if schema != SETTINGS_SCHEMA:
|
||||
raise ValueError(f"unsupported settings schema {schema}: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||
settings = Settings(
|
||||
width=_integer(value, "width", 1600),
|
||||
height=_integer(value, "height", 900),
|
||||
@@ -47,9 +140,15 @@ def load_settings(paths: AppPaths) -> Settings:
|
||||
refresh_rate=_integer(value, "refresh_rate", 60),
|
||||
fullscreen=_boolean(value, "fullscreen", False),
|
||||
borderless=_boolean(value, "borderless", True),
|
||||
suppress_unresponsive_warnings=_boolean(
|
||||
value,
|
||||
"suppress_unresponsive_warnings",
|
||||
True,
|
||||
),
|
||||
scaler=_string(value, "scaler", "fit"),
|
||||
filter=_string(value, "filter", "linear"),
|
||||
runtime=_string(value, "runtime", "auto"),
|
||||
proton_sync=_string(value, "proton_sync", "auto"),
|
||||
proton_path=_optional_string(value, "proton_path"),
|
||||
game_source=_optional_string(value, "game_source"),
|
||||
)
|
||||
@@ -57,25 +156,62 @@ def load_settings(paths: AppPaths) -> Settings:
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> None:
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
def _decode_settings(content: bytes) -> str:
|
||||
"""Accept native UTF-8 plus settings saved by common Windows editors."""
|
||||
|
||||
try:
|
||||
return content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as utf8_error:
|
||||
try:
|
||||
return content.decode("cp1252")
|
||||
except UnicodeDecodeError:
|
||||
raise utf8_error
|
||||
|
||||
|
||||
def _settings_need_migration(value: dict[str, object]) -> bool:
|
||||
return (
|
||||
value.get("schema") != SETTINGS_SCHEMA
|
||||
or not _SETTINGS_FIELD_NAMES.issubset(value)
|
||||
)
|
||||
|
||||
|
||||
def _write_settings(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
existing: dict[str, object] | None,
|
||||
) -> None:
|
||||
payload = dict(existing or {})
|
||||
payload.update(asdict(settings))
|
||||
payload["schema"] = SETTINGS_SCHEMA
|
||||
temporary = paths.settings_file.with_name(
|
||||
f".{paths.settings_file.name}.{os.getpid()}.tmp"
|
||||
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
|
||||
f"{secrets.token_hex(8)}.tmp"
|
||||
)
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(paths.settings_file)
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, paths.settings_file)
|
||||
_fsync_directory(paths.config)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
if paths.settings_file.exists():
|
||||
return load_settings(paths)
|
||||
settings = Settings()
|
||||
save_settings(paths, settings)
|
||||
return settings
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _validate(settings: Settings) -> None:
|
||||
@@ -90,11 +226,13 @@ def _validate(settings: Settings) -> None:
|
||||
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
|
||||
raise ValueError("refresh_rate must be between 24 and 1000")
|
||||
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
|
||||
raise ValueError(f"unsupported Gamescope scaler: {settings.scaler}")
|
||||
raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}")
|
||||
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
|
||||
raise ValueError(f"unsupported Gamescope filter: {settings.filter}")
|
||||
if settings.runtime not in {"auto", "proton7", "umu"}:
|
||||
raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
|
||||
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
|
||||
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
||||
if settings.proton_sync not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
|
||||
|
||||
|
||||
def _integer(value: dict[str, object], key: str, default: int) -> int:
|
||||
@@ -125,3 +263,6 @@ def _optional_string(value: dict[str, object], key: str) -> str | None:
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be null or a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))
|
||||
|
||||
503
ptlinux/ui.py
503
ptlinux/ui.py
@@ -6,6 +6,7 @@ import base64
|
||||
from collections import deque
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -21,6 +22,7 @@ from .install_status import (
|
||||
PrimaryAction,
|
||||
inspect_game_installation,
|
||||
)
|
||||
from .import_state import game_import_requires_verification
|
||||
from .news import (
|
||||
Broadcast,
|
||||
NewsArticle,
|
||||
@@ -28,10 +30,18 @@ from .news import (
|
||||
fetch_news,
|
||||
fetch_news_image,
|
||||
fetch_official_logo,
|
||||
read_cached_broadcast,
|
||||
read_cached_news,
|
||||
)
|
||||
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings, ensure_settings, save_settings
|
||||
from .self_update import LauncherUpdateCheck, check_for_launcher_update
|
||||
from .settings import ensure_settings, update_settings
|
||||
from .self_update import (
|
||||
LauncherUpdateCheck,
|
||||
cache_launcher_update_check,
|
||||
check_for_launcher_update,
|
||||
read_cached_launcher_update_check,
|
||||
)
|
||||
|
||||
|
||||
MAX_NEWS_CARDS = 3
|
||||
@@ -42,6 +52,10 @@ NEWS_IMAGE_HEIGHT = 118
|
||||
LAUNCH_STAGE_TOTAL = 4
|
||||
EVENT_BATCH_SIZE = 48
|
||||
POLL_INTERVAL_MILLISECONDS = 33
|
||||
ACTIVE_POLL_INTERVAL_MILLISECONDS = 75
|
||||
IDLE_POLL_INTERVAL_MILLISECONDS = 500
|
||||
MAX_ACTIVITY_LOG_LINES = 5000
|
||||
ACTIVITY_LOG_TRIM_LINES = 500
|
||||
TRANSFER_RATE_WINDOW_SECONDS = 3.0
|
||||
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
|
||||
TRANSFER_RATE_IDLE_SECONDS = 2.0
|
||||
@@ -51,6 +65,34 @@ class DashboardError(RuntimeError):
|
||||
"""Raised when the native dashboard cannot be displayed."""
|
||||
|
||||
|
||||
def launcher_restart_spec(
|
||||
paths: AppPaths,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""Build an isolated restart that imports the newly activated release."""
|
||||
|
||||
current = paths.launcher_current
|
||||
if not current.is_symlink():
|
||||
raise DashboardError(f"launcher current path is not a symbolic link: {current}")
|
||||
try:
|
||||
release = current.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise DashboardError(f"launcher current release is unavailable: {exc}") from exc
|
||||
releases = paths.launcher_releases.resolve(strict=False)
|
||||
if release.parent != releases or not (release / "ptlinux").is_dir():
|
||||
raise DashboardError(f"launcher current release is outside the release store: {release}")
|
||||
env = dict(os.environ if environment is None else environment)
|
||||
env.update(
|
||||
{
|
||||
"PTLINUX_INSTALL_DIR": str(paths.data),
|
||||
"PTLINUX_APP_HOME": str(paths.app_home),
|
||||
"PYTHONSAFEPATH": "1",
|
||||
"PYTHONPATH": str(release),
|
||||
}
|
||||
)
|
||||
return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env
|
||||
|
||||
|
||||
class ProgressCoalescer:
|
||||
"""Keep only the latest cross-thread progress event for the Tk loop.
|
||||
|
||||
@@ -151,12 +193,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
) from exc
|
||||
|
||||
paths.create_base_directories()
|
||||
ensure_settings(paths)
|
||||
root = tk.Tk()
|
||||
root.title("PristonTale EU — Linux Launcher")
|
||||
root.geometry(WINDOW_SIZE)
|
||||
root.resizable(False, False)
|
||||
root.configure(background="#0c0908")
|
||||
try:
|
||||
ensure_settings(paths)
|
||||
except (OSError, ValueError) as error:
|
||||
messagebox.showerror(
|
||||
"Launcher settings",
|
||||
f"The launcher could not load its settings:\n\n{error}",
|
||||
parent=root,
|
||||
)
|
||||
root.destroy()
|
||||
return
|
||||
|
||||
style = ttk.Style(root)
|
||||
style.theme_use("clam")
|
||||
@@ -564,13 +615,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
preloader_progress: ttk.Progressbar | None = None
|
||||
settings_window: tk.Toplevel | None = None
|
||||
operation_active = False
|
||||
game_window_active = False
|
||||
activity_log_lines = 0
|
||||
operation_started_at: float | None = None
|
||||
operation_stage = 0
|
||||
operation_stage_total = 0
|
||||
operation_message = ""
|
||||
operation_refreshes_compatibility = False
|
||||
last_elapsed_second = -1
|
||||
official_snapshot: DepotSnapshot | None = None
|
||||
launcher_update_check: LauncherUpdateCheck | None = None
|
||||
runtime_compatibility: RuntimeCompatibility | None = None
|
||||
compatibility_check_generation = 0
|
||||
buttons = (
|
||||
play_button,
|
||||
update_button,
|
||||
@@ -582,8 +638,16 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
|
||||
def append_log(value: str) -> None:
|
||||
nonlocal activity_log_lines
|
||||
rendered = value.rstrip() + "\n"
|
||||
log.configure(state="normal")
|
||||
log.insert("end", value.rstrip() + "\n")
|
||||
log.insert("end", rendered)
|
||||
activity_log_lines += rendered.count("\n")
|
||||
if activity_log_lines > MAX_ACTIVITY_LOG_LINES:
|
||||
retained_lines = MAX_ACTIVITY_LOG_LINES - ACTIVITY_LOG_TRIM_LINES
|
||||
excess = activity_log_lines - retained_lines
|
||||
log.delete("1.0", f"{excess + 1}.0")
|
||||
activity_log_lines -= excess
|
||||
log.see("end")
|
||||
log.configure(state="disabled")
|
||||
|
||||
@@ -753,18 +817,22 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
*,
|
||||
show_preloader: bool = False,
|
||||
restart_on_success: bool = False,
|
||||
game_files_may_change: bool = False,
|
||||
) -> None:
|
||||
nonlocal operation_active
|
||||
nonlocal operation_active, game_window_active
|
||||
nonlocal operation_started_at
|
||||
nonlocal operation_stage
|
||||
nonlocal operation_stage_total
|
||||
nonlocal operation_message
|
||||
nonlocal operation_refreshes_compatibility
|
||||
nonlocal last_elapsed_second
|
||||
operation_active = True
|
||||
game_window_active = False
|
||||
operation_started_at = time.monotonic()
|
||||
operation_stage = 0
|
||||
operation_stage_total = 0
|
||||
operation_message = ""
|
||||
operation_refreshes_compatibility = game_files_may_change
|
||||
last_elapsed_second = -1
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
@@ -785,6 +853,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
@@ -1068,8 +1138,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
installation = inspect_game_installation(
|
||||
paths,
|
||||
official_snapshot.build if official_snapshot is not None else None,
|
||||
compatibility_ready=(
|
||||
runtime_compatibility.compatible
|
||||
if runtime_compatibility is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
play_button.configure(text=installation.action.value)
|
||||
if not operation_active:
|
||||
play_button.configure(
|
||||
state=(
|
||||
"disabled"
|
||||
if installation.action is PrimaryAction.CHECKING
|
||||
else "normal"
|
||||
),
|
||||
)
|
||||
if not update_activity or operation_active:
|
||||
return installation
|
||||
if installation.action is PrimaryAction.INSTALL:
|
||||
@@ -1089,11 +1172,83 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
f"Installed depot {installation.local_build} · "
|
||||
f"official depot {official_build}"
|
||||
)
|
||||
elif installation.action is PrimaryAction.CHECKING:
|
||||
status.set("Checking game compatibility…")
|
||||
progress_text.set("The installed release is being inspected without modifying it.")
|
||||
elif installation.action is PrimaryAction.LAUNCHER_UPDATE:
|
||||
status.set("Launcher compatibility update required.")
|
||||
progress_text.set("This installed game release is not recognized by the launcher.")
|
||||
else:
|
||||
status.set("Ready")
|
||||
progress_text.set("Click Play to start your journey")
|
||||
return installation
|
||||
|
||||
def refresh_runtime_compatibility(*, update_activity: bool = True) -> None:
|
||||
nonlocal compatibility_check_generation, runtime_compatibility
|
||||
compatibility_check_generation += 1
|
||||
check_generation = compatibility_check_generation
|
||||
runtime_compatibility = None
|
||||
local_status = inspect_game_installation(paths)
|
||||
refresh_primary_action(update_activity=update_activity)
|
||||
if not local_status.installed or game_import_requires_verification(paths):
|
||||
return
|
||||
|
||||
def worker() -> None:
|
||||
result = inspect_runtime_compatibility(paths, paths.source_game)
|
||||
events.put(
|
||||
(
|
||||
"compatibility",
|
||||
(check_generation, result, update_activity),
|
||||
)
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=worker,
|
||||
name="ptlinux-compatibility",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def show_launcher_update_check(
|
||||
check: LauncherUpdateCheck,
|
||||
*,
|
||||
log_reason: bool = True,
|
||||
) -> None:
|
||||
nonlocal launcher_update_check
|
||||
launcher_update_check = check
|
||||
if check.available and check.supported:
|
||||
launcher_update_button.configure(
|
||||
text=f"UPDATE TO {check.latest_version}",
|
||||
state="normal",
|
||||
)
|
||||
elif check.available:
|
||||
launcher_update_button.configure(
|
||||
text="MANUAL UPDATE NEEDED",
|
||||
state="normal",
|
||||
)
|
||||
else:
|
||||
launcher_update_button.configure(
|
||||
text=f"LAUNCHER {__version__}",
|
||||
state="normal",
|
||||
)
|
||||
if log_reason:
|
||||
append_log(check.reason)
|
||||
|
||||
def refresh_launcher_update_check() -> None:
|
||||
def worker() -> None:
|
||||
try:
|
||||
check = check_for_launcher_update()
|
||||
except RuntimeError as update_error:
|
||||
events.put(("launcher_update_error", str(update_error)))
|
||||
else:
|
||||
cache_launcher_update_check(paths.launcher_update_check_cache, check)
|
||||
events.put(("launcher_update_check", check))
|
||||
|
||||
threading.Thread(
|
||||
target=worker,
|
||||
name="ptlinux-self-update",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def refresh_remote_content() -> None:
|
||||
def broadcast_worker() -> None:
|
||||
events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache)))
|
||||
@@ -1109,26 +1264,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
else:
|
||||
events.put(("version", snapshot))
|
||||
|
||||
def launcher_update_worker() -> None:
|
||||
try:
|
||||
check = check_for_launcher_update()
|
||||
except RuntimeError as update_error:
|
||||
events.put(("launcher_update_error", str(update_error)))
|
||||
else:
|
||||
events.put(("launcher_update_check", check))
|
||||
|
||||
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
|
||||
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
|
||||
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
|
||||
threading.Thread(
|
||||
target=launcher_update_worker,
|
||||
name="ptlinux-self-update",
|
||||
daemon=True,
|
||||
).start()
|
||||
refresh_launcher_update_check()
|
||||
|
||||
def finish_operation(returncode: int) -> None:
|
||||
nonlocal operation_active, operation_started_at
|
||||
nonlocal operation_active, game_window_active
|
||||
nonlocal operation_refreshes_compatibility, operation_started_at
|
||||
refresh_compatibility = operation_refreshes_compatibility
|
||||
operation_active = False
|
||||
game_window_active = False
|
||||
operation_refreshes_compatibility = False
|
||||
operation_started_at = None
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
@@ -1138,13 +1285,16 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
set_busy(False)
|
||||
refresh_primary_action(update_activity=returncode == 0)
|
||||
if refresh_compatibility:
|
||||
refresh_runtime_compatibility(update_activity=returncode == 0)
|
||||
else:
|
||||
refresh_primary_action(update_activity=returncode == 0)
|
||||
if returncode != 0:
|
||||
status.set(f"Operation failed (exit {returncode})")
|
||||
progress_text.set("See the activity log for details.")
|
||||
|
||||
def poll_events() -> None:
|
||||
nonlocal launcher_update_check
|
||||
nonlocal game_window_active, launcher_update_check, runtime_compatibility
|
||||
processed = 0
|
||||
try:
|
||||
while processed < EVENT_BATCH_SIZE:
|
||||
@@ -1159,8 +1309,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if value == 0:
|
||||
append_log("Launcher update activated; restarting the dashboard.")
|
||||
try:
|
||||
restart_command, restart_environment = launcher_restart_spec(paths)
|
||||
subprocess.Popen(
|
||||
command_for("gui"),
|
||||
restart_command,
|
||||
env=restart_environment,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
@@ -1180,6 +1332,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if step > 0 and total > 0 and isinstance(message, str) and message:
|
||||
set_operation_stage(step, total, message)
|
||||
elif kind in {"game_started", "game_window_ready"}:
|
||||
game_window_active = True
|
||||
progress_bar.stop()
|
||||
status.set("Game client started.")
|
||||
close_game_preloader()
|
||||
@@ -1192,6 +1345,29 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
install_news_image(value)
|
||||
elif kind == "logo_image":
|
||||
install_official_logo(value)
|
||||
elif (
|
||||
kind == "compatibility"
|
||||
and isinstance(value, tuple)
|
||||
and len(value) == 3
|
||||
and isinstance(value[0], int)
|
||||
and isinstance(value[1], RuntimeCompatibility)
|
||||
and isinstance(value[2], bool)
|
||||
):
|
||||
check_generation, checked, update_activity = value
|
||||
if check_generation == compatibility_check_generation:
|
||||
runtime_compatibility = checked
|
||||
if checked.compatible:
|
||||
append_log(
|
||||
"Compatibility check passed: "
|
||||
f"scene={checked.scene_action}, "
|
||||
f"binaries={checked.binary_action}"
|
||||
)
|
||||
else:
|
||||
append_log(
|
||||
"Launcher compatibility update required: "
|
||||
f"{checked.reason or 'unknown release profile'}"
|
||||
)
|
||||
refresh_primary_action(update_activity=update_activity)
|
||||
elif kind == "version" and isinstance(value, DepotSnapshot):
|
||||
version.set(
|
||||
f"Launcher {__version__} · {value.version} · depot {value.build}"
|
||||
@@ -1206,26 +1382,13 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
value,
|
||||
LauncherUpdateCheck,
|
||||
):
|
||||
launcher_update_check = value
|
||||
if value.available and value.supported:
|
||||
launcher_update_button.configure(
|
||||
text=f"UPDATE TO {value.latest_version}",
|
||||
state="normal",
|
||||
)
|
||||
elif value.available:
|
||||
launcher_update_button.configure(
|
||||
text="MANUAL UPDATE NEEDED",
|
||||
state="normal",
|
||||
)
|
||||
else:
|
||||
launcher_update_button.configure(
|
||||
text=f"LAUNCHER {__version__}",
|
||||
state="normal",
|
||||
)
|
||||
append_log(value.reason)
|
||||
show_launcher_update_check(value)
|
||||
elif kind == "launcher_update_error" and isinstance(value, str):
|
||||
launcher_update_check = None
|
||||
launcher_update_button.configure(text="CHECK LAUNCHER", state="normal")
|
||||
if launcher_update_check is None:
|
||||
launcher_update_button.configure(
|
||||
text="CHECK LAUNCHER",
|
||||
state="normal",
|
||||
)
|
||||
append_log(f"Launcher update check unavailable: {value}")
|
||||
except queue.Empty:
|
||||
pass
|
||||
@@ -1236,23 +1399,41 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if speed_text.get() != "0 B/s" and transfer_rate.is_idle():
|
||||
speed_text.set("0 B/s")
|
||||
update_elapsed_indicator()
|
||||
root.after(POLL_INTERVAL_MILLISECONDS, poll_events)
|
||||
root.after(
|
||||
dashboard_poll_interval(
|
||||
operation_active=operation_active,
|
||||
game_window_active=game_window_active,
|
||||
pending_events=not events.empty(),
|
||||
),
|
||||
poll_events,
|
||||
)
|
||||
|
||||
def command_for(*arguments: str) -> list[str]:
|
||||
return [sys.executable, "-m", "ptlinux", *arguments]
|
||||
|
||||
def run_launcher_update() -> None:
|
||||
def run_launcher_update(required_reason: str | None = None) -> None:
|
||||
check = launcher_update_check
|
||||
if check is None:
|
||||
launcher_update_button.configure(text="CHECKING…", state="disabled")
|
||||
refresh_remote_content()
|
||||
if required_reason is not None:
|
||||
status.set("Checking for a compatible launcher update…")
|
||||
progress_text.set("Play remains disabled until this release is supported.")
|
||||
refresh_launcher_update_check()
|
||||
return
|
||||
if not check.available:
|
||||
messagebox.showinfo(
|
||||
"Launcher update",
|
||||
check.reason,
|
||||
parent=root,
|
||||
)
|
||||
if required_reason is None:
|
||||
messagebox.showinfo(
|
||||
"Launcher update",
|
||||
check.reason,
|
||||
parent=root,
|
||||
)
|
||||
else:
|
||||
messagebox.showwarning(
|
||||
"Compatibility update required",
|
||||
"The installed game needs a launcher compatibility update:\n\n"
|
||||
f"{required_reason}\n\n{check.reason}",
|
||||
parent=root,
|
||||
)
|
||||
return
|
||||
if not check.supported:
|
||||
messagebox.showwarning(
|
||||
@@ -1287,11 +1468,19 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
run_operation(
|
||||
"Installing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
else:
|
||||
elif installation.action is PrimaryAction.UPDATE:
|
||||
run_operation(
|
||||
"Synchronizing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
elif installation.action is PrimaryAction.LAUNCHER_UPDATE:
|
||||
run_launcher_update(
|
||||
runtime_compatibility.reason
|
||||
if runtime_compatibility is not None
|
||||
else "The installed release profile is not recognized.",
|
||||
)
|
||||
|
||||
play_button.configure(command=run_primary_action)
|
||||
@@ -1299,12 +1488,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
command=lambda: run_operation(
|
||||
"Synchronizing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
)
|
||||
repair_button.configure(
|
||||
command=lambda: run_operation(
|
||||
"Checking every game file against the official depot…",
|
||||
command_for("update", "--no-import", "--repair"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
)
|
||||
doctor_button.configure(
|
||||
@@ -1319,7 +1510,15 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
settings_window.focus_set()
|
||||
return
|
||||
|
||||
current_settings = ensure_settings(paths)
|
||||
try:
|
||||
current_settings = ensure_settings(paths)
|
||||
except (OSError, ValueError) as error:
|
||||
messagebox.showerror(
|
||||
"Settings",
|
||||
f"The launcher could not load its settings:\n\n{error}",
|
||||
parent=root,
|
||||
)
|
||||
return
|
||||
dialog = tk.Toplevel(root)
|
||||
settings_window = dialog
|
||||
dialog.title("PristonTale EU — Settings")
|
||||
@@ -1350,7 +1549,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
).pack(anchor="w", pady=(1, 0))
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="Tune your display, rendering, and compatibility preferences.",
|
||||
text="Tune the game window, runtime, and compatibility preferences.",
|
||||
background="#24170f",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
@@ -1361,54 +1560,15 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
content.columnconfigure(0, weight=1, uniform="settings-card")
|
||||
content.columnconfigure(1, weight=1, uniform="settings-card")
|
||||
|
||||
width_value = tk.StringVar(value=str(current_settings.width))
|
||||
height_value = tk.StringVar(value=str(current_settings.height))
|
||||
output_width_value = tk.StringVar(value=str(current_settings.output_width))
|
||||
output_height_value = tk.StringVar(value=str(current_settings.output_height))
|
||||
refresh_rate_value = tk.StringVar(value=str(current_settings.refresh_rate))
|
||||
fullscreen_value = tk.BooleanVar(value=current_settings.fullscreen)
|
||||
borderless_value = tk.BooleanVar(value=current_settings.borderless)
|
||||
scaler_value = tk.StringVar(value=current_settings.scaler)
|
||||
filter_value = tk.StringVar(value=current_settings.filter)
|
||||
suppress_unresponsive_value = tk.BooleanVar(
|
||||
value=current_settings.suppress_unresponsive_warnings
|
||||
)
|
||||
runtime_value = tk.StringVar(value=current_settings.runtime)
|
||||
proton_sync_value = tk.StringVar(value=current_settings.proton_sync)
|
||||
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
||||
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
||||
|
||||
def add_dimensions(
|
||||
parent: ttk.LabelFrame,
|
||||
row: int,
|
||||
label: str,
|
||||
description: str,
|
||||
width_variable: tk.StringVar,
|
||||
height_variable: tk.StringVar,
|
||||
) -> None:
|
||||
ttk.Label(parent, text=label, style="SettingsText.TLabel").grid(
|
||||
row=row,
|
||||
column=0,
|
||||
sticky="w",
|
||||
)
|
||||
ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid(
|
||||
row=row,
|
||||
column=1,
|
||||
sticky="e",
|
||||
)
|
||||
fields = ttk.Frame(parent, style="SettingsCard.TFrame")
|
||||
fields.grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9))
|
||||
ttk.Label(fields, text="Width", style="SettingsHint.TLabel").pack(side="left")
|
||||
ttk.Entry(
|
||||
fields,
|
||||
textvariable=width_variable,
|
||||
width=8,
|
||||
style="Settings.TEntry",
|
||||
).pack(side="left", padx=(5, 12))
|
||||
ttk.Label(fields, text="Height", style="SettingsHint.TLabel").pack(side="left")
|
||||
ttk.Entry(
|
||||
fields,
|
||||
textvariable=height_variable,
|
||||
width=8,
|
||||
style="Settings.TEntry",
|
||||
).pack(side="left", padx=(5, 0))
|
||||
|
||||
def add_choice(
|
||||
parent: ttk.LabelFrame,
|
||||
row: int,
|
||||
@@ -1491,41 +1651,32 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
settings_window.destroy()
|
||||
settings_window = None
|
||||
|
||||
def integer_value(variable: tk.StringVar, name: str) -> int:
|
||||
value = variable.get().strip()
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{name} must be a whole number") from error
|
||||
|
||||
def save_current_settings() -> None:
|
||||
try:
|
||||
updated_settings = Settings(
|
||||
width=integer_value(width_value, "Client width"),
|
||||
height=integer_value(height_value, "Client height"),
|
||||
output_width=integer_value(output_width_value, "Output width"),
|
||||
output_height=integer_value(output_height_value, "Output height"),
|
||||
refresh_rate=integer_value(refresh_rate_value, "Refresh rate"),
|
||||
fullscreen=fullscreen_value.get(),
|
||||
updated_settings = update_settings(
|
||||
paths,
|
||||
borderless=borderless_value.get(),
|
||||
scaler=scaler_value.get(),
|
||||
filter=filter_value.get(),
|
||||
suppress_unresponsive_warnings=suppress_unresponsive_value.get(),
|
||||
runtime=runtime_value.get(),
|
||||
proton_sync=proton_sync_value.get(),
|
||||
proton_path=proton_path_value.get().strip() or None,
|
||||
game_source=game_source_value.get().strip() or None,
|
||||
)
|
||||
save_settings(paths, updated_settings)
|
||||
except (OSError, ValueError) as error:
|
||||
messagebox.showerror("Settings", str(error), parent=dialog)
|
||||
return
|
||||
append_log(f"Saved launcher settings to {paths.settings_file}")
|
||||
append_log(
|
||||
f"Saved launcher settings to {paths.settings_file}: "
|
||||
f"runtime={updated_settings.runtime}, "
|
||||
f"proton_sync={updated_settings.proton_sync}"
|
||||
)
|
||||
status.set("Settings saved. They will be used on the next game launch.")
|
||||
progress_text.set("Open Settings again at any time to adjust the launcher.")
|
||||
close_settings()
|
||||
|
||||
display_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="DISPLAY",
|
||||
text="GAME WINDOW",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
@@ -1534,66 +1685,31 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
display_card.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
display_card,
|
||||
text="Choose the game canvas and the Gamescope output it is presented on.",
|
||||
text=(
|
||||
"The launcher uses the proven client resolution and can remove the "
|
||||
"new game window's host decorations."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=360,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
add_dimensions(
|
||||
display_card,
|
||||
1,
|
||||
"Game resolution",
|
||||
"Internal client size",
|
||||
width_value,
|
||||
height_value,
|
||||
)
|
||||
add_dimensions(
|
||||
display_card,
|
||||
3,
|
||||
"Output resolution",
|
||||
"Gamescope desktop",
|
||||
output_width_value,
|
||||
output_height_value,
|
||||
)
|
||||
ttk.Label(display_card, text="Refresh rate", style="SettingsText.TLabel").grid(
|
||||
row=5,
|
||||
column=0,
|
||||
sticky="w",
|
||||
)
|
||||
ttk.Label(
|
||||
display_card,
|
||||
text="Output refresh",
|
||||
style="SettingsHint.TLabel",
|
||||
).grid(row=5, column=1, sticky="e")
|
||||
refresh_fields = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
||||
refresh_fields.grid(row=6, column=0, columnspan=2, sticky="w", pady=(3, 8))
|
||||
ttk.Entry(
|
||||
refresh_fields,
|
||||
textvariable=refresh_rate_value,
|
||||
width=8,
|
||||
style="Settings.TEntry",
|
||||
).pack(side="left")
|
||||
ttk.Label(refresh_fields, text="Hz", style="SettingsHint.TLabel").pack(
|
||||
side="left",
|
||||
padx=(5, 0),
|
||||
)
|
||||
presentation = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
||||
presentation.grid(row=7, column=0, columnspan=2, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Fullscreen",
|
||||
variable=fullscreen_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(side="left")
|
||||
presentation.grid(row=1, column=0, columnspan=2, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Borderless",
|
||||
variable=borderless_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(side="left", padx=(18, 0))
|
||||
).pack(anchor="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Suppress false 'not responding' warnings",
|
||||
variable=suppress_unresponsive_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(anchor="w", pady=(7, 0))
|
||||
|
||||
rendering_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="RENDERING & RUNTIME",
|
||||
text="RUNTIME",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
@@ -1602,40 +1718,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
rendering_card.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
rendering_card,
|
||||
text="Control how the client is scaled and which compatible runtime launches it.",
|
||||
text="Choose which compatibility runtime launches the proven game setup.",
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=360,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
add_choice(
|
||||
rendering_card,
|
||||
1,
|
||||
"Scaling mode",
|
||||
"Gamescope layout",
|
||||
scaler_value,
|
||||
("auto", "integer", "fit", "fill", "stretch"),
|
||||
"Runtime",
|
||||
"Compatibility layer",
|
||||
runtime_value,
|
||||
("auto", "proton7-container", "proton7", "umu"),
|
||||
)
|
||||
add_choice(
|
||||
rendering_card,
|
||||
3,
|
||||
"Image filter",
|
||||
"Upscaling filter",
|
||||
filter_value,
|
||||
("linear", "nearest", "fsr", "nis", "pixel"),
|
||||
)
|
||||
ttk.Separator(rendering_card).grid(
|
||||
row=5,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
pady=(2, 9),
|
||||
)
|
||||
add_choice(
|
||||
rendering_card,
|
||||
6,
|
||||
"Runtime",
|
||||
"Compatibility layer",
|
||||
runtime_value,
|
||||
("auto", "proton7", "umu"),
|
||||
"Proton synchronization",
|
||||
"Auto uses esync on CachyOS",
|
||||
proton_sync_value,
|
||||
("auto", "fsync", "esync"),
|
||||
)
|
||||
|
||||
overrides_card = ttk.LabelFrame(
|
||||
@@ -1662,8 +1763,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
add_path(
|
||||
overrides_card,
|
||||
3,
|
||||
"Game source",
|
||||
"Import from this existing game folder",
|
||||
"Import source",
|
||||
"Seed a new managed installation only",
|
||||
game_source_value,
|
||||
choose_game_source,
|
||||
)
|
||||
@@ -1673,7 +1774,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
footer,
|
||||
text="Settings are stored locally and used the next time you press Play.",
|
||||
text="Only the settings shown here affect the next launcher operation.",
|
||||
style="SettingsFooter.TLabel",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame")
|
||||
@@ -1707,6 +1808,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
run_operation(
|
||||
"Importing the existing game into the managed installation…",
|
||||
command_for("import-game", source),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
|
||||
import_button.configure(command=choose_import_source)
|
||||
@@ -1722,7 +1824,15 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
news_content.bind("<Configure>", update_news_viewport)
|
||||
news_canvas.bind("<Configure>", resize_news_content)
|
||||
show_broadcast(read_cached_broadcast(paths.broadcast_cache))
|
||||
show_news(read_cached_news(paths.news_cache))
|
||||
cached_launcher_update = read_cached_launcher_update_check(
|
||||
paths.launcher_update_check_cache
|
||||
)
|
||||
if cached_launcher_update is not None:
|
||||
show_launcher_update_check(cached_launcher_update, log_reason=False)
|
||||
refresh_primary_action()
|
||||
refresh_runtime_compatibility()
|
||||
poll_events()
|
||||
load_official_logo()
|
||||
refresh_remote_content()
|
||||
@@ -1739,6 +1849,21 @@ def _as_int(value: object) -> int:
|
||||
return value if isinstance(value, int) and value >= 0 else 0
|
||||
|
||||
|
||||
def dashboard_poll_interval(
|
||||
*,
|
||||
operation_active: bool,
|
||||
game_window_active: bool,
|
||||
pending_events: bool,
|
||||
) -> int:
|
||||
"""Choose a responsive poll rate while avoiding busy idle/game polling."""
|
||||
|
||||
if pending_events:
|
||||
return POLL_INTERVAL_MILLISECONDS
|
||||
if operation_active and not game_window_active:
|
||||
return ACTIVE_POLL_INTERVAL_MILLISECONDS
|
||||
return IDLE_POLL_INTERVAL_MILLISECONDS
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
units = ("B", "KiB", "MiB", "GiB")
|
||||
amount = float(value)
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Build the verified V21 texture-reclaim runtime DLL from a local game DLL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
|
||||
SOURCE_SHA256 = "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868"
|
||||
PATCHED_SHA256 = "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
TEXT_VA = 0x10001000
|
||||
TEXT_FILE_OFFSET = 0x400
|
||||
ENTRY_VA = 0x107C0B40
|
||||
ENTRY_FILE_OFFSET = TEXT_FILE_OFFSET + (ENTRY_VA - TEXT_VA)
|
||||
ENTRY_ORIGINAL = bytes.fromhex("55 8b ec 6a ff")
|
||||
CAVE_VA = 0x11195CE0
|
||||
CAVE_FILE_OFFSET = TEXT_FILE_OFFSET + (CAVE_VA - TEXT_VA)
|
||||
TEXT_RAW_SIZE = 0x1194E00
|
||||
MAX_HOOK_SIZE = TEXT_FILE_OFFSET + TEXT_RAW_SIZE - CAVE_FILE_OFFSET
|
||||
HOOK = bytes.fromhex(
|
||||
"9c60e8490136ff85c0744a89c38d70088dbe505d010039fe743b80be58030000"
|
||||
"00742a8b068b4c242885c974083b81d41f000074188b4c242c85c974083b81d4"
|
||||
"1f000074085089d9e8a32036ff81c650040000ebc1619d5589e56affe904ae62"
|
||||
"ff"
|
||||
)
|
||||
|
||||
|
||||
class TextureReclaimPatchError(RuntimeError):
|
||||
"""Raised when the exact V21 binary profile cannot be patched safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextureReclaimPatchResult:
|
||||
source: str
|
||||
destination: str
|
||||
source_sha256: str
|
||||
destination_sha256: str
|
||||
entry_va: str
|
||||
cave_va: str
|
||||
hook_size: int
|
||||
old_text_virtual_size: int
|
||||
new_text_virtual_size: int
|
||||
commands: tuple[tuple[str, ...], ...]
|
||||
created_at: str
|
||||
|
||||
|
||||
def build_texture_reclaim_patch(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
) -> TextureReclaimPatchResult:
|
||||
"""Create the V21 patch in a separate runtime cache, never in the game tree."""
|
||||
source = source.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != SOURCE_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"source game.dll does not match the verified V21 profile"
|
||||
)
|
||||
|
||||
hook = HOOK
|
||||
commands: list[tuple[str, ...]] = []
|
||||
if not hook or len(hook) > MAX_HOOK_SIZE:
|
||||
raise TextureReclaimPatchError(
|
||||
f"compiled hook size {len(hook)} exceeds the {MAX_HOOK_SIZE}-byte code cave"
|
||||
)
|
||||
|
||||
image = bytearray(source.read_bytes())
|
||||
if bytes(image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + len(ENTRY_ORIGINAL)]) != ENTRY_ORIGINAL:
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 map-loader entry bytes do not match the verified profile"
|
||||
)
|
||||
if any(image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)]):
|
||||
raise TextureReclaimPatchError("V21 code cave is not empty")
|
||||
|
||||
text_header = _find_pe_section(image, b".text")
|
||||
old_virtual_size, text_rva, raw_size, raw_offset = struct.unpack_from(
|
||||
"<IIII", image, text_header + 8
|
||||
)
|
||||
if (text_rva, raw_size, raw_offset) != (
|
||||
TEXT_VA - 0x10000000,
|
||||
TEXT_RAW_SIZE,
|
||||
TEXT_FILE_OFFSET,
|
||||
):
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 executable section layout does not match the verified profile"
|
||||
)
|
||||
new_virtual_size = CAVE_VA - TEXT_VA + len(hook)
|
||||
if new_virtual_size > TEXT_RAW_SIZE:
|
||||
raise TextureReclaimPatchError("compiled hook extends beyond the executable section")
|
||||
if old_virtual_size > new_virtual_size:
|
||||
raise TextureReclaimPatchError(
|
||||
"verified executable section is already larger than the patch target"
|
||||
)
|
||||
|
||||
entry_displacement = CAVE_VA - (ENTRY_VA + 5)
|
||||
image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + 5] = b"\xe9" + struct.pack(
|
||||
"<i", entry_displacement
|
||||
)
|
||||
image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)] = hook
|
||||
struct.pack_into("<I", image, text_header + 8, new_virtual_size)
|
||||
|
||||
destination = destination.expanduser().absolute()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(image)
|
||||
if _sha256(temporary) != PATCHED_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"built runtime patch does not match the verified V21 profile"
|
||||
)
|
||||
temporary.replace(destination)
|
||||
shutil.copystat(source, destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return TextureReclaimPatchResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
source_sha256=SOURCE_SHA256,
|
||||
destination_sha256=PATCHED_SHA256,
|
||||
entry_va=f"0x{ENTRY_VA:08x}",
|
||||
cave_va=f"0x{CAVE_VA:08x}",
|
||||
hook_size=len(hook),
|
||||
old_text_virtual_size=old_virtual_size,
|
||||
new_text_virtual_size=new_virtual_size,
|
||||
commands=tuple(commands),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _find_pe_section(image: bytes | bytearray, section_name: bytes) -> int:
|
||||
if len(image) < 0x40:
|
||||
raise TextureReclaimPatchError("source is too small to be a PE image")
|
||||
pe_offset = struct.unpack_from("<I", image, 0x3C)[0]
|
||||
if image[pe_offset : pe_offset + 4] != b"PE\0\0":
|
||||
raise TextureReclaimPatchError("source does not have a valid PE signature")
|
||||
number_of_sections = struct.unpack_from("<H", image, pe_offset + 6)[0]
|
||||
optional_header_size = struct.unpack_from("<H", image, pe_offset + 20)[0]
|
||||
section_table = pe_offset + 24 + optional_header_size
|
||||
for index in range(number_of_sections):
|
||||
header = section_table + index * 40
|
||||
name = bytes(image[header : header + 8]).rstrip(b"\0")
|
||||
if name == section_name:
|
||||
return header
|
||||
raise TextureReclaimPatchError(f"PE section is missing: {section_name!r}")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ptdiag"
|
||||
version = "0.5.2"
|
||||
name = "PTEU-Linux-Launcher"
|
||||
version = "0.6.5"
|
||||
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
11
updates/stable.json
Normal file
11
updates/stable.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"archive_sha256": "c1132f2e2d74579ea29fa59d69cb1df881800445be11d17e0fff7bc5d34c12fa",
|
||||
"archive_size": 226431,
|
||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.6.0/pristontale-eu-linux-update-v0.6.0.tar.gz",
|
||||
"channel": "stable",
|
||||
"minimum_updater_version": "0.5.0",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.6.0",
|
||||
"published_at": "2026-08-29T21:16:21Z",
|
||||
"schema": 1,
|
||||
"version": "0.6.0"
|
||||
}
|
||||
BIN
updates/stable.json.sig
Normal file
BIN
updates/stable.json.sig
Normal file
Binary file not shown.
Reference in New Issue
Block a user