43 Commits

Author SHA1 Message Date
e8d77f3779 Released v0.8.1. 2026-09-01 20:17:37 +02:00
6900c20734 Prepare release v0.8.1 2026-09-01 20:09:16 +02:00
f96aba9d99 Fixed multiple clients bug in umu. 2026-09-01 20:05:54 +02:00
78dade0d86 Fixed some proton 7 path issues and the override in settings. 2026-09-01 19:07:00 +02:00
d819809197 New release: v0.8.0 2026-08-31 23:30:39 +02:00
dadae8f60a Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over. 2026-08-31 23:23:37 +02:00
99982b12d9 Added debugger class for dashboard and other launcher util classes. 2026-08-31 23:13:04 +02:00
75b21acf87 Added windows identity checker. 2026-08-31 23:12:20 +02:00
b16540099d Added changelog class 2026-08-31 23:11:21 +02:00
b0855ccd9b Added import class. 2026-08-31 23:10:38 +02:00
87dee93b86 Reworked the modal ui's for launcher, settings, updates and game pre-loader. 2026-08-31 22:03:47 +02:00
fe883047e0 Added a debug system and fixed thread issues with update / repair. 2026-08-31 20:23:23 +02:00
20a0bbcd75 Removed player counter. 2026-08-31 17:46:08 +02:00
ed0c278a8b Released v0.7.0. 2026-08-30 22:53:00 +02:00
094c881798 Added tabs for news / patches, integrated new patches api from website. 2026-08-30 22:47:55 +02:00
bc87f10661 Added server status and server selection dropdown. 2026-08-30 22:15:32 +02:00
562c924809 Fixed chmod issues with launcher updater. 2026-08-30 22:06:52 +02:00
94a5e81555 Released new stable version 0.6.7. 2026-08-30 12:38:05 +02:00
94f2260f43 Added support for multiple clients to be ran at the same time. 2026-08-30 12:32:39 +02:00
994fda5cc7 Added proper hotbar/menu icons and added a preloader for the launcher. 2026-08-30 12:06:15 +02:00
2a7da33160 Pushed new v0.6.5 update data. 2026-08-30 11:34:04 +02:00
0a38fc60f2 New release v0.6.5. 2026-08-30 11:27:45 +02:00
61700df17e Cleanup and security updates. 2026-08-30 11:24:02 +02:00
ae21f6c64a Updated launcher to v0.6.0, first stable release. 2026-08-30 00:09:18 +02:00
bef8d04d72 Fixed issues with encodings and wine / proton compatibilities. 2026-08-30 00:08:41 +02:00
d1587b785a Retired the login trace, it might interfere with anticheat. 2026-08-28 23:35:54 +02:00
1e1f76a000 Improvements to try resolve the issues with login checksums. 2026-08-28 22:54:46 +02:00
06672589ad Cache and cpu / mem improvements for launcher when idle. 2026-08-28 22:53:40 +02:00
d20eea6877 Reworked cache for news and update manifest. 2026-08-28 22:48:19 +02:00
c49c56bc97 Reverted experimental fix as game checks dll byte changes. 2026-08-28 21:56:13 +02:00
e90cb788ba Added game code 1 to logs. 2026-08-28 21:55:24 +02:00
010fe14ab4 Added more diagnostics to the native world fixer and py logs system. 2026-08-28 20:56:26 +02:00
b344506071 More changes and fixes to use the new native world (game.dll) fixes. 2026-08-28 20:34:31 +02:00
7f6330a617 Added controller for the native world helper. 2026-08-28 20:33:48 +02:00
fccac400a4 Added native game.dll checks and fixes to combat issues with logins and spawning into the world. 2026-08-28 20:33:05 +02:00
e5a1e1b781 Added login diagnostics and tracing for login issues. 2026-08-28 20:32:07 +02:00
f6e2d0923b Reworked launcher updates to use the big button. 2026-08-28 20:30:06 +02:00
9d3ec58f0c Improvements to the installer. 2026-08-28 20:17:04 +02:00
614d5d96c9 Security improvements to locking down play button and make sure one process only runs at once. 2026-08-28 20:16:34 +02:00
30a08c787c new user data storage, to preserve data better. 2026-08-28 20:15:57 +02:00
b37b5291ae More fixes to support arch distros. 2026-08-28 20:15:32 +02:00
d2c66b6316 Some minor changes to support arch distros. 2026-08-28 20:14:35 +02:00
e515080171 Added fallbacks for fsync issues on CachyOS. 2026-08-27 22:02:31 +02:00
44 changed files with 8529 additions and 845 deletions

23
.gitignore vendored
View File

@@ -3,8 +3,25 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.idea/ .idea/
dist/ .run/
release-keys/ release-keys/
config/local.toml config/local.toml
results/*
!results/.gitkeep # Local development, investigation, and release tooling. The production
# repository intentionally contains only the installer/runtime sources.
AGENTS.md
AIO_INSTALLER.md
BOOTSTRAP_WORKPLAN.md
DEBUGGING.md
GITEA_UPDATES.md
README.md
config/
configs/
findings/
replay_pid*.log
results/
testcases/
tests/
tools/
dist/
/native/

View File

@@ -1,17 +1,25 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.5.2 installs, updates, repairs, and launches PristonTale EU through a Version 0.8.1 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
## Install ## 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 ```text
sha256sum -c pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
tar -xzf pristontale-eu-linux-installer-v0-5-2.tar.gz openssl dgst -sha256 -verify launcher-update-public.pem \
cd pristontale-eu-linux-installer-v0-5-2 -signature pristontale-eu-linux-installer-v0-8-1.tar.gz.sig \
pristontale-eu-linux-installer-v0-8-1.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-8-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-8-1.tar.gz
cd pristontale-eu-linux-installer-v0-8-1
./install.sh ./install.sh
``` ```
@@ -26,6 +34,11 @@ Existing installations at the previous default location are preserved during
upgrades. The installer adds **PristonTale EU** to the application menu and upgrades. The installer adds **PristonTale EU** to the application menu and
installs the `pristontale-eu` command in `~/.local/bin`. installs the `pristontale-eu` command in `~/.local/bin`.
Steam Proton 7.0 is discovered in both the default Steam library and secondary
libraries registered with the Steam client. If Proton uses an unregistered
manual layout, open **Settings → Optional Overrides** and select the exact
`Proton 7.0/proton` script.
## Use ## Use
Open **PristonTale EU** from the application menu or run: Open **PristonTale EU** from the application menu or run:
@@ -37,11 +50,6 @@ pristontale-eu gui
The main launcher button automatically shows **Install**, **Update**, or The main launcher button automatically shows **Install**, **Update**, or
**Play** according to the managed game's state. **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 ## Commands
```text ```text
@@ -49,16 +57,22 @@ pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components pristontale-eu doctor Check required host components
pristontale-eu login-diagnostics Check the login route without credentials 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 Install or update official files
pristontale-eu update --repair Verify and repair every game file pristontale-eu update --repair Verify and repair every game file
pristontale-eu update --dry-run Preview an update pristontale-eu update --dry-run Preview an update
pristontale-eu import-game "/path/to/game" Import an existing installation pristontale-eu import-game "/path/to/game" Import an existing installation
pristontale-eu prepare Build the safe runtime view pristontale-eu prepare Build the safe runtime view
pristontale-eu play Launch through Gamescope/Proton pristontale-eu play Launch through the managed Proton window
pristontale-eu play --no-box Launch without Gamescope 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 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 --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 Install and activate an update
pristontale-eu launcher-update --rollback Restore the previous launcher pristontale-eu launcher-update --rollback Restore the previous launcher
pristontale-eu sync Sync without import discovery pristontale-eu sync Sync without import discovery
@@ -66,7 +80,6 @@ pristontale-eu wine-update Run the legacy WPF updater
pristontale-eu fetch Fetch the publisher launcher pristontale-eu fetch Fetch the publisher launcher
pristontale-eu setup Prepare its Wine environment pristontale-eu setup Prepare its Wine environment
pristontale-eu cache-compatibility PATH Cache a verified compatibility pair 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 Use `./install.sh --dry-run` to preview dependency installation or

View File

@@ -1 +0,0 @@
cd0314c9fba1779dac5bbe24190c174941dbed89c371b68808fefa37de7750bf pristontale-eu-linux-installer-v0-5-2.tar.gz

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002 pristontale-eu-linux-update-v0.5.2.tar.gz

11
dist/stable.json vendored
View File

@@ -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

Binary file not shown.

View File

@@ -3,12 +3,18 @@
set -euo pipefail set -euo pipefail
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" 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]+$ ]] if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then then
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2 printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
exit 2 exit 2
fi 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}" DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
BIN_HOME="${HOME}/.local/bin" BIN_HOME="${HOME}/.local/bin"
APPLICATION_HOME="${DATA_HOME}/applications" APPLICATION_HOME="${DATA_HOME}/applications"
@@ -133,10 +139,12 @@ RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \ || ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \ || ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ || ! -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.desktop" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
@@ -190,17 +198,32 @@ then
exit 1 exit 1
fi fi
install -d -m 0755 "$STAGING_RELEASE" install -d -m 0755 "$STAGING_RELEASE"
install -d -m 0755 "${STAGING_RELEASE}/ptlinux" "${STAGING_RELEASE}/ptdiag" install -d -m 0755 \
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/" "${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 \ install -m 0644 \
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"${STAGING_RELEASE}/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/__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/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" printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
chmod 0644 "${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)' \ '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" "$STAGING_RELEASE" "$VERSION"
then then
@@ -277,7 +300,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
ln -s -- "releases/${VERSION}" "$CURRENT_LINK" ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" 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" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \
"$APP_ROOT" \ "$APP_ROOT" \
"$INSTALL_DIRECTORY" \ "$INSTALL_DIRECTORY" \
@@ -285,7 +308,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
install -m 0644 \ install -m 0644 \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"$ICON_PATH" "$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" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${BIN_HOME}/pristontale-eu" \ "${BIN_HOME}/pristontale-eu" \
"$ICON_PATH" \ "$ICON_PATH" \
@@ -301,8 +324,44 @@ then
update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true
fi fi
printf '%s\n' "Application installed." printf '%s\n' "Launcher files installed."
DOCTOR_READY=1
if (( RUN_DOCTOR == 1 )) if (( RUN_DOCTOR == 1 ))
then 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 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."

View File

@@ -8,3 +8,4 @@ Terminal=false
Categories=Game;RolePlaying; Categories=Game;RolePlaying;
Keywords=PristonTale;MMORPG;Wine;Proton; Keywords=PristonTale;MMORPG;Wine;Proton;
StartupNotify=true StartupNotify=true
StartupWMClass=PristonTaleEU

View File

@@ -121,6 +121,9 @@ ptlinux_resolve_dependency_packages()
arch:openssl) arch:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
arch:umu)
candidates=(umu-launcher)
;;
suse:python) suse:python)
candidates=(python3) candidates=(python3)
;; ;;
@@ -210,6 +213,7 @@ ptlinux_has_wine_stack()
ptlinux_detect_missing_dependencies() ptlinux_detect_missing_dependencies()
{ {
local python_bin="${PYTHON_BIN:-python3}" local python_bin="${PYTHON_BIN:-python3}"
local family="${1:-}"
PTLINUX_MISSING_DEPENDENCIES=() PTLINUX_MISSING_DEPENDENCIES=()
if ! command -v "$python_bin" >/dev/null 2>&1 if ! command -v "$python_bin" >/dev/null 2>&1
@@ -227,10 +231,6 @@ ptlinux_detect_missing_dependencies()
then then
PTLINUX_MISSING_DEPENDENCIES+=(winetricks) PTLINUX_MISSING_DEPENDENCIES+=(winetricks)
fi fi
if ! command -v gamescope >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(gamescope)
fi
if ! command -v cabextract >/dev/null 2>&1 if ! command -v cabextract >/dev/null 2>&1
then then
PTLINUX_MISSING_DEPENDENCIES+=(cabextract) PTLINUX_MISSING_DEPENDENCIES+=(cabextract)
@@ -239,6 +239,10 @@ ptlinux_detect_missing_dependencies()
then then
PTLINUX_MISSING_DEPENDENCIES+=(openssl) PTLINUX_MISSING_DEPENDENCIES+=(openssl)
fi fi
if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(umu)
fi
} }
ptlinux_select_privilege_command() ptlinux_select_privilege_command()
@@ -344,15 +348,14 @@ ptlinux_install_host_dependencies()
local package_line local package_line
local -a packages=() 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 )) if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
then then
printf '%s\n' "All required host dependencies are already available." printf '%s\n' "All required host dependencies are already available."
return 0 return 0
fi fi
ptlinux_read_distribution
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
if [[ "$family" == "unknown" ]] if [[ "$family" == "unknown" ]]
then then
printf 'Unsupported distribution: %s. Re-run with --skip-deps after installing: %s\n' \ 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)." printf '%s\n' "Installing missing host dependencies (authentication may be requested)."
ptlinux_install_resolved_packages "$family" "${packages[@]}" ptlinux_install_resolved_packages "$family" "${packages[@]}"
hash -r hash -r
ptlinux_detect_missing_dependencies ptlinux_detect_missing_dependencies "$family"
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} != 0 )) if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} != 0 ))
then then
printf 'Dependencies remain unavailable after package installation: %s\n' \ printf 'Dependencies remain unavailable after package installation: %s\n' \

View File

@@ -16,7 +16,7 @@ do
then then
continue continue
fi 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' \ 'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
"${candidate}" >/dev/null 2>&1 "${candidate}" >/dev/null 2>&1
then then
@@ -31,11 +31,11 @@ then
exit 1 exit 1
fi fi
if [ -n "${PYTHONPATH:-}" ] # Never let an unpacked older installer in the caller's current directory
then # shadow the selected installed release. Children started by the dashboard
export PYTHONPATH="${release_root}:${PYTHONPATH}" # inherit the same safe-path policy, package root, and no-bytecode policy.
else export PYTHONSAFEPATH=1
export PYTHONPATH="${release_root}" export PYTHONDONTWRITEBYTECODE=1
fi export PYTHONPATH="${release_root}"
exec "$python_bin" -m ptlinux "$@" exec "$python_bin" -B -P -m ptlinux "$@"

View File

@@ -23,6 +23,11 @@ _FAILED_IMPORT_RE = re.compile(
r"Failed to import scene:\s*(?P<scene>.*)$", r"Failed to import scene:\s*(?P<scene>.*)$",
re.IGNORECASE, 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( _TIMESTAMP_RE = re.compile(
r"^\s*(?:\[)?(?P<timestamp>" r"^\s*(?:\[)?(?P<timestamp>"
r"\d{4}-\d{2}-\d{2}[T ][0-9:.+Z-]+|\d{2}:\d{2}:\d{2}(?:\.\d+)?)" 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 import_scene_threw: bool = False
stage_without_new_stage: bool = False stage_without_new_stage: bool = False
tex_reclaim_count: int = 0 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: def latest_scene(self, name: str) -> SceneImport | None:
wanted = _scene_key(name) wanted = _scene_key(name)
@@ -95,6 +105,11 @@ class NativeLogAnalysis:
"failed_files": [event.to_dict() for event in self.failed_files], "failed_files": [event.to_dict() for event in self.failed_files],
"stage_without_new_stage": self.stage_without_new_stage, "stage_without_new_stage": self.stage_without_new_stage,
"tex_reclaim_count": self.tex_reclaim_count, "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 ( for output_key, scene_name in (
("whispering_vale", "WhisperingVale"), ("whispering_vale", "WhisperingVale"),
@@ -243,6 +258,27 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
analysis.import_scene_threw = True analysis.import_scene_threw = True
analysis.events.append(LogEvent("import_scene_threw", line_number, raw, timestamp)) 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) failed_import = _FAILED_IMPORT_RE.search(raw)
if failed_import is not None: if failed_import is not None:
value = failed_import.group("scene").strip() value = failed_import.group("scene").strip()

View File

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

332
ptlinux/changelog.py Normal file
View File

@@ -0,0 +1,332 @@
"""Official patch changelog support for the native Linux dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import json
import math
import os
from pathlib import Path
import re
from typing import Callable, Protocol
from urllib.request import Request, urlopen
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
PATCH_NEWS_URL = "https://pristontale.eu/news#"
MAX_PATCHES = 10
MAX_PATCH_BYTES = 2 * 1024 * 1024
MAX_PATCH_SECTIONS = 32
MAX_PATCH_CHANGES = 200
_MAX_PATCH_CANDIDATES = 100
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
class ChangelogError(RuntimeError):
"""Raised when the official patch feed cannot be parsed safely."""
class ChangelogResponse(Protocol):
"""The minimal response interface used by the changelog client."""
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> ChangelogResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
ChangelogOpener = Callable[..., ChangelogResponse]
@dataclass(frozen=True)
class PatchChange:
text: str
type_key: str
@property
def tag(self) -> str:
return patch_change_tag(self.type_key)
@dataclass(frozen=True)
class PatchSection:
title: str
items: tuple[PatchChange, ...]
@dataclass(frozen=True)
class PatchNote:
id: int
slug: str
title: str
version: str | None
tags: tuple[str, ...]
summary: str
sections: tuple[PatchSection, ...]
published_at: str | None
@property
def url(self) -> str:
return f"{PATCH_NEWS_URL}{self.slug}"
def patch_change_tag(type_key: str) -> str:
"""Turn API keys such as ``new-feature`` into compact UI tags."""
label = " ".join(type_key.replace("_", "-").split("-"))
return label.upper() if label else "CHANGE"
def fetch_patch_notes(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: ChangelogOpener = urlopen,
) -> tuple[PatchNote, ...]:
"""Fetch the ten latest patches, falling back to the last valid cache."""
request = Request(
PATCHES_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_PATCH_BYTES + 1)
if len(payload) > MAX_PATCH_BYTES:
raise ChangelogError("official patch response exceeds the 2 MiB limit")
patches = _parse_patch_notes(payload)
_write_patch_cache(cache_path, patches)
return patches
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
return read_cached_patch_notes(cache_path)
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
"""Read the last valid patch feed without performing network I/O."""
try:
value = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return ()
if not isinstance(value, list):
return ()
patches: list[PatchNote] = []
for item in value[:MAX_PATCHES]:
patch = _patch_note_from_cache(item)
if patch is not None:
patches.append(patch)
return tuple(patches)
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
value = json.loads(payload.decode("utf-8-sig"))
if isinstance(value, dict):
value = value.get("data")
if not isinstance(value, list):
raise ChangelogError("official patch response is not a patch list")
parsed: list[tuple[float, int, PatchNote]] = []
for item in value[:_MAX_PATCH_CANDIDATES]:
result = _parse_patch_note(item)
if result is not None:
timestamp, patch = result
parsed.append((timestamp, patch.id, patch))
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
if not patches:
raise ChangelogError("official patch response contains no usable patches")
return patches
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
version = _clean_text(value.get("version"), 64, allow_none=True)
if value.get("version") is not None and version is None:
return None
timestamp = _timestamp(value.get("date"))
patch = PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=_parse_sections(value.get("changes")),
published_at=_format_timestamp(timestamp),
)
return (timestamp if timestamp is not None else -1.0, patch)
def _parse_tags(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
tags: list[str] = []
seen: set[str] = set()
for item in value[:16]:
tag = _clean_text(item, 48)
if tag is None or tag.casefold() in seen:
continue
seen.add(tag.casefold())
tags.append(tag)
return tuple(tags)
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
if not isinstance(value, dict):
return ()
sections_value = value.get("sections")
if not isinstance(sections_value, list):
return ()
sections: list[PatchSection] = []
changes_seen = 0
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
title = _clean_text(section_value.get("title"), 100) or "Changes"
items_value = section_value.get("items")
if not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if changes_seen >= MAX_PATCH_CHANGES:
break
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is None:
continue
items.append(
PatchChange(
text=text,
type_key=type_key or _type_key_from_title(title),
)
)
changes_seen += 1
if items:
sections.append(PatchSection(title=title, items=tuple(items)))
if changes_seen >= MAX_PATCH_CHANGES:
break
return tuple(sections)
def _patch_note_from_cache(value: object) -> PatchNote | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
version = _clean_text(value.get("version"), 64, allow_none=True)
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
sections_value = value.get("sections")
sections: list[PatchSection] = []
if isinstance(sections_value, list):
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
section_title = _clean_text(section_value.get("title"), 100)
items_value = section_value.get("items")
if section_title is None or not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is not None and type_key is not None:
items.append(PatchChange(text=text, type_key=type_key))
if items:
sections.append(PatchSection(section_title, tuple(items)))
return PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=tuple(sections),
published_at=published_at,
)
def _clean_text(
value: object,
maximum_length: int,
*,
allow_empty: bool = False,
allow_none: bool = False,
) -> str | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
return None
text = " ".join(value.split())
if len(text) > maximum_length or (not text and not allow_empty):
return None
return text
def _timestamp(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
timestamp = float(value)
return timestamp if math.isfinite(timestamp) else None
def _format_timestamp(value: float | None) -> str | None:
if value is None:
return None
try:
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
except (OverflowError, OSError, ValueError):
return None
def _type_key_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)

View File

@@ -9,14 +9,28 @@ from datetime import datetime
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Sequence import time
from typing import Callable, Sequence
from ptdiag.logs import (
LogFileState,
newest_current_native_log,
parse_current_run_log,
snapshot_native_logs,
)
from . import __version__ from . import __version__
from .command import CommandError, CommandRunner from .command import CommandError, CommandRunner
from .depot import SyncProgress, sync_managed_game from .depot import (
SyncProgress,
read_cached_depot_launch_catalog,
select_depot_start_option,
sync_managed_game,
)
from .doctor import inspect_host from .doctor import inspect_host
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
from .importer import GameImportResult, discover_existing_game, import_existing_game 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 ( from .launcher import (
LauncherError, LauncherError,
configure_launcher_prefix, configure_launcher_prefix,
@@ -24,21 +38,24 @@ from .launcher import (
launch_official_updater, launch_official_updater,
prepare_launcher_binary, prepare_launcher_binary,
) )
from .locking import launcher_operation_lock from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock
from .login_diagnostics import inspect_login_environment 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 .paths import AppPaths
from .runtime import ( from .runtime import (
game_runtime_ready,
prepare_game_runtime, prepare_game_runtime,
run_game, run_game,
select_runtime, 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 ( from .self_update import (
check_for_launcher_update, check_for_launcher_update,
install_launcher_update, install_launcher_update,
rollback_launcher_update, rollback_launcher_update,
update_check_to_dict, update_check_to_dict,
verify_launcher_update_channel,
) )
@@ -67,6 +84,15 @@ def build_parser() -> argparse.ArgumentParser:
default=5.0, default=5.0,
help="TCP connection timeout in seconds (default: 5)", 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 = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher")
fetch.add_argument("--dry-run", action="store_true") fetch.add_argument("--dry-run", action="store_true")
@@ -122,27 +148,58 @@ def build_parser() -> argparse.ArgumentParser:
help="cache one exact verified Game.exe/game.dll compatibility pair", help="cache one exact verified Game.exe/game.dll compatibility pair",
) )
compatibility.add_argument("source", type=Path) 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 = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
prepare.add_argument("--game-dir", type=Path) 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("--game-dir", type=Path)
play.add_argument("--no-box", action="store_true", help="launch without Gamescope") play.add_argument(
"--realm",
help="launch an available realm from the last official depot manifest",
)
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("--dry-run", action="store_true")
play.add_argument("--skip-runtime-setup", 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 = subparsers.add_parser("settings", help="show, initialize, or reset settings")
settings.add_argument("--init", action="store_true", help="write conservative defaults") 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 = subparsers.add_parser(
"launcher-update", "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 = launcher_update.add_mutually_exclusive_group()
launcher_action.add_argument( launcher_action.add_argument(
@@ -150,6 +207,11 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="authenticate the update manifest without installing it", 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( launcher_action.add_argument(
"--rollback", "--rollback",
action="store_true", action="store_true",
@@ -181,7 +243,12 @@ def main(argv: Sequence[str] | None = None) -> int:
if command == "doctor": if command == "doctor":
return _doctor(arguments.json) return _doctor(arguments.json)
if command == "login-diagnostics": 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": if command == "fetch":
return _fetch(paths, arguments.dry_run, arguments.json) return _fetch(paths, arguments.dry_run, arguments.json)
if command == "setup": if command == "setup":
@@ -229,10 +296,6 @@ def main(argv: Sequence[str] | None = None) -> int:
result = cache_compatibility_binaries(paths, arguments.source) result = cache_compatibility_binaries(paths, arguments.source)
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json) _emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
return 0 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": if command == "prepare":
return _prepare(paths, arguments.game_dir, arguments.json) return _prepare(paths, arguments.game_dir, arguments.json)
if command == "play": if command == "play":
@@ -243,19 +306,37 @@ def main(argv: Sequence[str] | None = None) -> int:
arguments.dry_run, arguments.dry_run,
arguments.skip_runtime_setup, arguments.skip_runtime_setup,
arguments.json, arguments.json,
realm=arguments.realm,
)
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": if command == "settings":
return _settings(paths, arguments.init, arguments.json) return _settings(paths, arguments.init, arguments.reset, arguments.json)
if command == "launcher-update": if command == "launcher-update":
return _launcher_update( return _launcher_update(
paths, paths,
arguments.check, arguments.check,
arguments.verify,
arguments.rollback, arguments.rollback,
arguments.manifest_url, arguments.manifest_url,
arguments.public_key, arguments.public_key,
arguments.json, arguments.json,
) )
parser.error(f"unknown command: {command}") 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: except (ValueError, OSError, RuntimeError) as exc:
if arguments.json: if arguments.json:
_print_json({"ok": False, "error": str(exc)}) _print_json({"ok": False, "error": str(exc)})
@@ -287,25 +368,55 @@ def _doctor(as_json: bool) -> int:
return 0 if report.ok else 1 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) settings = _load_or_default_settings(paths, persist=False)
report = inspect_login_environment( runner = _runner(paths, "login-diagnostics", False)
paths, with _operation_guard(paths, "login-diagnostics", False):
settings, report = inspect_login_environment(
timeout_seconds=timeout_seconds, paths,
) settings,
timeout_seconds=timeout_seconds,
runner=runner,
runtime_override=(
None if runtime_choice == "configured" else runtime_choice
),
)
if as_json: if as_json:
_print_json(report.to_dict()) _print_json(report.to_dict())
else: else:
probe = report.login_server probe = report.login_server
print(f"Launcher: {report.launcher_version} ({report.launcher_module})") print(f"Launcher: {report.launcher_version} ({report.launcher_module})")
print(f"Python: {report.python_executable}") 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" status = "REACHABLE" if probe.reachable else "UNREACHABLE"
latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else "" 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})") print(f"Login server: {status} ({probe.host}:{probe.port}{latency})")
if probe.error: if probe.error:
print(f"Network error: {probe.error}") print(f"Network error: {probe.error}")
print("Authentication: not tested; no credentials or game-protocol data were sent") 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 profile: {report.game_profile}")
print(f"Game.exe: {report.game_exe}") print(f"Game.exe: {report.game_exe}")
if report.game_exe_sha256: if report.game_exe_sha256:
@@ -313,10 +424,24 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -
print(f"game.dll: {report.game_dll}") print(f"game.dll: {report.game_dll}")
if report.game_dll_sha256: if report.game_dll_sha256:
print(f"DLL SHA-256: {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: if report.runtime_kind:
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})") print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
else: else:
print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})") 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" prefix_status = "ready" if report.prefix_ready else "not ready"
directx_status = "ready" if report.directx_ready else "not ready" directx_status = "ready" if report.directx_ready else "not ready"
print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}") print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}")
@@ -414,13 +539,22 @@ def _native_update(
runner, runner,
announce=not as_json, 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…") _print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
result = sync_managed_game( result = sync_managed_game(
paths, paths,
repair=repair, repair=repair or verify_import,
quarantine_unmanaged=verify_import,
dry_run=dry_run, dry_run=dry_run,
progress=None if as_json else _print_sync_progress, 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 overlay = None
if not dry_run and paths.source_game.is_dir(): if not dry_run and paths.source_game.is_dir():
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") _print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
@@ -469,6 +603,23 @@ def _wine_update(
announce=not as_json, announce=not as_json,
) )
updater = launch_official_updater(paths, tools, runner) 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 overlay = None
if not dry_run and paths.source_game.is_dir(): if not dry_run and paths.source_game.is_dir():
overlay = asdict(prepare_runtime_view(paths, paths.source_game)) overlay = asdict(prepare_runtime_view(paths, paths.source_game))
@@ -477,6 +628,9 @@ def _wine_update(
"dry_run": dry_run, "dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None, "game_import": asdict(game_import) if game_import else None,
"updater": asdict(updater), "updater": asdict(updater),
"native_verification": (
asdict(native_verification) if native_verification is not None else None
),
"runtime_view": overlay, "runtime_view": overlay,
} }
_emit(value, as_json) _emit(value, as_json)
@@ -544,9 +698,15 @@ def _maybe_import_game(
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
settings = ensure_settings(paths) with _operation_guard(paths, "prepare", False):
source = _source_game(paths, settings, game_dir) if game_dir is None and game_import_requires_verification(paths):
result = prepare_runtime_view(paths, source) 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)
_emit({"ok": True, "runtime_view": asdict(result)}, as_json) _emit({"ok": True, "runtime_view": asdict(result)}, as_json)
return 0 return 0
@@ -558,46 +718,250 @@ def _play(
dry_run: bool, dry_run: bool,
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
*,
realm: str | None = None,
) -> int: ) -> int:
with _play_guard(paths, dry_run) as play_lease:
return _play_locked(
paths,
game_dir,
no_box,
dry_run,
skip_runtime_setup,
as_json,
realm=realm,
reuse_prepared=not play_lease.preparation_allowed,
before_game=play_lease.allow_shared_clients,
)
def _play_locked(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
reuse_prepared: bool = False,
before_game: Callable[[], None] | None = None,
) -> 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) settings = _load_or_default_settings(paths, persist=not dry_run)
start_option = select_depot_start_option(
read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
realm or settings.realm,
)
source = _source_game(paths, settings, game_dir) source = _source_game(paths, settings, game_dir)
overlay = None overlay = None
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…") _print_operation_status(
if not dry_run: as_json,
1,
4,
(
"Reusing the active managed game view…"
if reuse_prepared
else "Preparing the managed game view…"
),
)
if reuse_prepared:
if game_dir is not None:
raise LauncherError(
"an additional client cannot replace the active managed game view; "
"close every client before using --game-dir"
)
if not paths.runtime_view.is_dir():
raise LauncherError(
"another game client is active, but the prepared runtime view is missing"
)
elif not dry_run:
overlay = prepare_runtime_view(paths, source) overlay = prepare_runtime_view(paths, source)
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…") _print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
selection = select_runtime(settings) selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run) runner = _runner(paths, "play", dry_run)
setup_results = [] setup_results = []
if not skip_runtime_setup: if reuse_prepared:
_print_operation_status(
as_json,
3,
4,
"Reusing the active Proton and DirectX runtime…",
)
if not game_runtime_ready(paths, selection):
raise LauncherError(
"another game client is active, but its prepared runtime no longer "
"matches the current settings; close every client and launch again"
)
elif not skip_runtime_setup:
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
setup_results = prepare_game_runtime(paths, selection, runner) setup_results = prepare_game_runtime(paths, selection, runner)
else:
_print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
outcome = run_game( native_log_directory = paths.game_save / "logs"
paths, native_log_baseline = snapshot_native_logs(
settings, native_log_directory,
selection, patterns=("log_*.txt",),
runner,
use_gamebox=not no_box,
) )
game_started_ns = time.time_ns()
if before_game is not None:
before_game()
try:
outcome = run_game(
paths,
settings,
selection,
runner,
start_option=start_option,
use_gamebox=not no_box,
concurrent_prefix_launch=reuse_prepared,
)
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( _emit(
{ {
"ok": outcome.returncode == 0, "ok": outcome.returncode == 0,
"dry_run": dry_run, "dry_run": dry_run,
"runtime": asdict(selection), "runtime": asdict(selection),
"realm": asdict(start_option),
"runtime_view": asdict(overlay) if overlay else None, "runtime_view": asdict(overlay) if overlay else None,
"setup_commands": [asdict(result) for result in setup_results], "setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome), "game": asdict(outcome),
"current_game_log": current_game_log,
"runtime_issues": runtime_issues,
}, },
as_json, as_json,
) )
return 0 if outcome.returncode == 0 else 1 return 0 if outcome.returncode == 0 else 1
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int: def _current_game_log(
if initialize: directory: Path,
save_settings(paths, Settings()) baseline: dict[str, LogFileState],
settings = ensure_settings(paths) if initialize else load_settings(paths) 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( _emit(
{ {
"ok": True, "ok": True,
@@ -612,6 +976,7 @@ def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
def _launcher_update( def _launcher_update(
paths: AppPaths, paths: AppPaths,
check_only: bool, check_only: bool,
verify: bool,
rollback: bool, rollback: bool,
manifest_url: str | None, manifest_url: str | None,
public_key: Path | None, public_key: Path | None,
@@ -640,6 +1005,21 @@ def _launcher_update(
as_json, as_json,
) )
return 0 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…") _print_operation_status(as_json, 1, 2, "Verifying the signed launcher release…")
result = install_launcher_update( result = install_launcher_update(
paths, paths,
@@ -659,14 +1039,18 @@ def _launcher_update(
def _source_game( def _source_game(
paths: AppPaths, paths: AppPaths,
settings: Settings, _settings: Settings,
argument: Path | None, argument: Path | None,
) -> Path: ) -> 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: if argument is not None:
return argument.expanduser().resolve() return argument.expanduser().resolve()
source_override = settings.source_override
if source_override is not None:
return source_override
return paths.source_game return paths.source_game
@@ -679,10 +1063,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run) return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
def _play_guard(
paths: AppPaths,
dry_run: bool,
) -> AbstractContextManager[LauncherPlayLease]:
if dry_run:
return nullcontext(LauncherPlayLease.dry_run())
return launcher_play_lock(paths.launcher_operation_lock)
def _operation_guard( def _operation_guard(
paths: AppPaths, paths: AppPaths,
operation: str, operation: str,

View File

@@ -2,16 +2,61 @@
from __future__ import annotations from __future__ import annotations
from collections import deque
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
import json import json
import os import os
from pathlib import Path from pathlib import Path
import signal
import subprocess import subprocess
import sys import sys
import threading import threading
import time 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) @dataclass(frozen=True)
@@ -53,6 +98,7 @@ class CommandRunner:
timeout_seconds: float = 300.0, timeout_seconds: float = 300.0,
check: bool = True, check: bool = True,
stream_output: bool = False, stream_output: bool = False,
output_line_filter: OutputLineFilter | None = None,
) -> CommandOutcome: ) -> CommandOutcome:
arguments = [str(value) for value in argv] arguments = [str(value) for value in argv]
if not arguments: if not arguments:
@@ -81,6 +127,7 @@ class CommandRunner:
environment=environment, environment=environment,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
started=started, started=started,
output_line_filter=output_line_filter,
) )
else: else:
try: try:
@@ -89,6 +136,8 @@ class CommandRunner:
cwd=str(cwd) if cwd is not None else None, cwd=str(cwd) if cwd is not None else None,
env=environment, env=environment,
text=True, text=True,
encoding=SUBPROCESS_ENCODING,
errors=SUBPROCESS_DECODE_ERRORS,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout_seconds, timeout=timeout_seconds,
@@ -97,8 +146,8 @@ class CommandRunner:
outcome = CommandOutcome( outcome = CommandOutcome(
argv=arguments, argv=arguments,
returncode=completed.returncode, returncode=completed.returncode,
stdout=completed.stdout, stdout=_bounded_text(completed.stdout),
stderr=completed.stderr, stderr=_bounded_text(completed.stderr),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=False, timed_out=False,
dry_run=False, dry_run=False,
@@ -107,8 +156,8 @@ class CommandRunner:
outcome = CommandOutcome( outcome = CommandOutcome(
argv=arguments, argv=arguments,
returncode=None, returncode=None,
stdout=_as_text(exc.stdout), stdout=_bounded_text(_as_text(exc.stdout)),
stderr=_as_text(exc.stderr), stderr=_bounded_text(_as_text(exc.stderr)),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=True, timed_out=True,
dry_run=False, dry_run=False,
@@ -126,31 +175,43 @@ class CommandRunner:
environment: Mapping[str, str], environment: Mapping[str, str],
timeout_seconds: float, timeout_seconds: float,
started: float, started: float,
output_line_filter: OutputLineFilter | None,
) -> CommandOutcome: ) -> CommandOutcome:
process = subprocess.Popen( process = subprocess.Popen(
arguments, arguments,
cwd=str(cwd) if cwd is not None else None, cwd=str(cwd) if cwd is not None else None,
env=dict(environment), env=dict(environment),
text=True, text=True,
encoding=SUBPROCESS_ENCODING,
errors=SUBPROCESS_DECODE_ERRORS,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
bufsize=1, bufsize=1,
start_new_session=True,
) )
stdout: list[str] = [] stdout = BoundedTextCapture()
stderr: list[str] = [] stderr = BoundedTextCapture()
def relay( def relay(
stream: TextIO, stream: TextIO,
capture: list[str], capture: BoundedTextCapture,
destination: TextIO, destination: TextIO,
) -> None: ) -> None:
while True: try:
line = stream.readline() while True:
if not line: line = stream.readline()
return if not line:
capture.append(line) return
destination.write(line) if output_line_filter is not None:
destination.flush() 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.stdout is not None
assert process.stderr is not None assert process.stderr is not None
@@ -171,17 +232,17 @@ class CommandRunner:
returncode = process.wait(timeout=timeout_seconds) returncode = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
timed_out = True timed_out = True
process.kill() returncode = _terminate_process_group(process)
returncode = process.wait() except BaseException:
stdout_thread.join() _terminate_process_group(process)
stderr_thread.join() _finish_stream_relays(process, stdout_thread, stderr_thread)
process.stdout.close() raise
process.stderr.close() _finish_stream_relays(process, stdout_thread, stderr_thread)
return CommandOutcome( return CommandOutcome(
argv=arguments, argv=arguments,
returncode=None if timed_out else returncode, returncode=None if timed_out else returncode,
stdout="".join(stdout), stdout=stdout.getvalue(),
stderr="".join(stderr), stderr=stderr.getvalue(),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=timed_out, timed_out=timed_out,
dry_run=False, dry_run=False,
@@ -210,3 +271,53 @@ def _as_text(value: bytes | str | None) -> str:
if isinstance(value, bytes): if isinstance(value, bytes):
return value.decode("utf-8", errors="replace") return value.decode("utf-8", errors="replace")
return value 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)

213
ptlinux/dashboard_debug.py Normal file
View File

@@ -0,0 +1,213 @@
"""Read-only state snapshots for the launcher's hidden debug view."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import json
from pathlib import Path
from typing import Callable
from .import_state import game_import_requires_verification
from .paths import AppPaths
from .runtime import (
RuntimeErrorPT,
RuntimeSelection,
resolve_proton_sync_mode,
select_runtime,
)
from .settings import Settings
@dataclass(frozen=True)
class RuntimeDebugInfo:
"""Configured, selected, and last-prepared runtime state."""
requested_kind: str
selected_kind: str | None
executable: str | None
proton_path: str | None
prefix: str | None
sync_configured: str
sync_effective: str | None
prepared_kind: str | None
prepared_proton_path: str | None
prepared_at: str | None
marker_matches_selection: bool | None
dxvk_d3d9_override: str | None
error: str | None
@dataclass(frozen=True)
class VerificationDebugInfo:
"""Cached trust evidence from completed depot and import operations."""
import_state: str
import_verified_at: str | None
import_build: int | None
last_sync_at: str | None
last_full_verification_at: str | None
build: int | None
version: str | None
mode: str | None
files_total: int | None
files_downloaded: int | None
files_deleted: int | None
files_created: int | None
files_quarantined: int | None
RuntimeSelector = Callable[[Settings], RuntimeSelection]
SyncResolver = Callable[[str], str]
def inspect_runtime_debug(
paths: AppPaths,
settings: Settings,
*,
selector: RuntimeSelector = select_runtime,
sync_resolver: SyncResolver = resolve_proton_sync_mode,
) -> RuntimeDebugInfo:
"""Resolve the next runtime and compare it with the preparation marker."""
marker = _read_json_object(paths.game_runtime_marker)
prepared_runtime: dict[str, object] | None = None
if marker is not None:
raw_prepared_runtime = marker.get("runtime")
if isinstance(raw_prepared_runtime, dict):
prepared_runtime = raw_prepared_runtime
prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None
prepared_proton_path = (
_text(prepared_runtime.get("proton_path")) if prepared_runtime else None
)
prepared_at = _text(marker.get("configured_at")) if marker else None
dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None
selection: RuntimeSelection | None = None
error: str | None = None
try:
selection = selector(settings)
except (OSError, RuntimeErrorPT, ValueError) as exc:
error = str(exc).strip() or type(exc).__name__
selected_kind = selection.kind if selection is not None else None
prefix_kind = selected_kind or prepared_kind
prefix = None
if prefix_kind == "umu":
prefix = str(paths.umu_prefix)
elif prefix_kind in {"proton7", "proton7-container"}:
prefix = str(paths.proton_prefix)
sync_effective = None
if prefix_kind in {"proton7", "proton7-container"}:
try:
sync_effective = sync_resolver(settings.proton_sync)
except ValueError as exc:
error = error or (str(exc).strip() or type(exc).__name__)
marker_matches = (
prepared_runtime == asdict(selection)
if prepared_runtime is not None and selection is not None
else None
)
return RuntimeDebugInfo(
requested_kind=settings.runtime,
selected_kind=selected_kind,
executable=selection.executable if selection is not None else None,
proton_path=selection.proton_path if selection is not None else None,
prefix=prefix,
sync_configured=settings.proton_sync,
sync_effective=sync_effective,
prepared_kind=prepared_kind,
prepared_proton_path=prepared_proton_path,
prepared_at=prepared_at,
marker_matches_selection=marker_matches,
dxvk_d3d9_override=dxvk_override,
error=error,
)
def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo:
"""Read cached verification state without hashing or changing game files."""
depot = _read_json_object(paths.native_depot_state)
if depot is not None and depot.get("dry_run") is not False:
depot = None
import_manifest_exists = paths.game_import_manifest.exists()
import_manifest = _read_json_object(paths.game_import_manifest)
if not import_manifest_exists:
import_state = "not applicable"
elif game_import_requires_verification(paths):
import_state = "pending"
else:
import_state = "verified"
import_verified_at = (
_text(import_manifest.get("verification_completed_at"))
if import_state == "verified" and import_manifest is not None
else None
)
import_build = (
_non_negative_int(import_manifest.get("verification_build"))
if import_state == "verified" and import_manifest is not None
else None
)
last_sync_at = _text(depot.get("completed_at")) if depot else None
full_verifications = [
value
for value in (
last_sync_at if depot is not None and depot.get("repair") is True else None,
import_verified_at,
)
if value is not None
]
last_full_verification_at = (
max(full_verifications, key=_iso_timestamp)
if full_verifications
else None
)
return VerificationDebugInfo(
import_state=import_state,
import_verified_at=import_verified_at,
import_build=import_build,
last_sync_at=last_sync_at,
last_full_verification_at=last_full_verification_at,
build=_non_negative_int(depot.get("build")) if depot else None,
version=_text(depot.get("version")) if depot else None,
mode=(
"full repair"
if depot is not None and depot.get("repair") is True
else "update"
if depot is not None and depot.get("repair") is False
else None
),
files_total=_non_negative_int(depot.get("files_total")) if depot else None,
files_downloaded=(
_non_negative_int(depot.get("files_downloaded")) if depot else None
),
files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None,
files_created=_non_negative_int(depot.get("files_created")) if depot else None,
files_quarantined=(
_non_negative_int(depot.get("files_quarantined")) if depot else None
),
)
def _read_json_object(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 _text(value: object) -> str | None:
return value.strip() if isinstance(value, str) and value.strip() else None
def _non_negative_int(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
def _iso_timestamp(value: str) -> float:
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except (OverflowError, ValueError):
return float("-inf")

View File

@@ -13,6 +13,7 @@ import json
import os import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import re import re
import stat
import threading import threading
import time import time
from typing import Callable, Iterable, Mapping, Protocol from typing import Callable, Iterable, Mapping, Protocol
@@ -38,8 +39,41 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
MAX_LINE_ENDING_RECOVERY_BYTES = 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}$") SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
DEPOT_LAUNCH_CATALOG_SCHEMA = 1
class DepotError(RuntimeError): class DepotError(RuntimeError):
@@ -72,6 +106,22 @@ class CreateIfMissing:
content_base64: str content_base64: str
@dataclass(frozen=True)
class DepotStartOption:
"""One publisher-advertised realm launch target."""
name: str
path: str
arguments: str
DEFAULT_DEPOT_START_OPTION = DepotStartOption(
name="Babel",
path="Game.exe",
arguments="PTEU_REBORN",
)
@dataclass(frozen=True) @dataclass(frozen=True)
class DepotSnapshot: class DepotSnapshot:
"""The current, verified shape of the public game depot.""" """The current, verified shape of the public game depot."""
@@ -84,6 +134,101 @@ class DepotSnapshot:
files: tuple[DepotFile, ...] files: tuple[DepotFile, ...]
deleted_files: tuple[str, ...] deleted_files: tuple[str, ...]
create_if_missing: tuple[CreateIfMissing, ...] create_if_missing: tuple[CreateIfMissing, ...]
start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
default_start_option: str = DEFAULT_DEPOT_START_OPTION.name
@dataclass(frozen=True)
class DepotLaunchCatalog:
"""Small cached subset of a depot manifest needed for offline Play."""
build: int
start_options: tuple[DepotStartOption, ...]
default_start_option: str
def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog:
return DepotLaunchCatalog(
build=snapshot.build,
start_options=snapshot.start_options,
default_start_option=snapshot.default_start_option,
)
def select_depot_start_option(
catalog: DepotLaunchCatalog | None,
preferred_realm: str | None = None,
) -> DepotStartOption:
"""Choose a published realm, falling back to Babel when possible."""
current = catalog or DepotLaunchCatalog(
build=0,
start_options=(DEFAULT_DEPOT_START_OPTION,),
default_start_option=DEFAULT_DEPOT_START_OPTION.name,
)
if preferred_realm:
preferred = preferred_realm.casefold()
for option in current.start_options:
if option.name.casefold() == preferred:
return option
for option in current.start_options:
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold():
return option
for option in current.start_options:
if option.name.casefold() == current.default_start_option.casefold():
return option
return current.start_options[0]
def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None:
"""Cache only publisher-advertised launch metadata for network-free Play."""
catalog = launch_catalog_from_snapshot(snapshot)
_write_json(
path,
{
"schema": DEPOT_LAUNCH_CATALOG_SCHEMA,
"build": catalog.build,
"default_start_option": catalog.default_start_option,
"start_options": [asdict(option) for option in catalog.start_options],
},
)
def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None:
"""Read the last valid launch catalog without performing network I/O."""
try:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
return None
if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA:
return None
build = _positive_int(value.get("build"), "cached depot build")
raw_options = value.get("start_options")
if not isinstance(raw_options, list):
return None
manifest_options = [
{
"Name": item.get("name"),
"Path": item.get("path"),
"Arguments": item.get("arguments"),
}
if isinstance(item, dict)
else item
for item in raw_options
]
options, default_name = _parse_start_options(
manifest_options,
value.get("default_start_option"),
)
except (OSError, UnicodeError, json.JSONDecodeError, DepotError):
return None
return DepotLaunchCatalog(
build=build,
start_options=options,
default_start_option=default_name,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -129,11 +274,21 @@ class DepotSyncResult:
bytes_downloaded: int bytes_downloaded: int
files_deleted: int files_deleted: int
files_created: int files_created: int
files_quarantined: int
quarantine_directory: str | None
repair: bool repair: bool
dry_run: bool dry_run: bool
completed_at: str 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] ProgressCallback = Callable[[SyncProgress], None]
@@ -453,7 +608,11 @@ def plan_update(
continue continue
except OSError as exc: except OSError as exc:
raise DepotError(f"cannot inspect {file.path}: {exc}") from 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(): if _sha1(target).casefold() != file.sha1.casefold():
downloads.append(PlannedDownload(file, "hash mismatch")) downloads.append(PlannedDownload(file, "hash mismatch"))
@@ -479,6 +638,7 @@ def sync_managed_game(
paths: AppPaths, paths: AppPaths,
*, *,
repair: bool = False, repair: bool = False,
quarantine_unmanaged: bool = False,
dry_run: bool = False, dry_run: bool = False,
progress: ProgressCallback | None = None, progress: ProgressCallback | None = None,
client: DepotClient | None = None, client: DepotClient | None = None,
@@ -489,6 +649,9 @@ def sync_managed_game(
depot_client = client or DepotClient() depot_client = client or DepotClient()
_emit_progress(progress, "manifest", 0, 0, 0, 0, None) _emit_progress(progress, "manifest", 0, 0, 0, 0, None)
snapshot = depot_client.fetch_snapshot() 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) plan = plan_update(paths.source_game, snapshot, repair=repair)
_emit_progress( _emit_progress(
progress, progress,
@@ -501,7 +664,16 @@ def sync_managed_game(
) )
if dry_run: 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) root = _prepare_game_root(paths.source_game, create=True)
_download_plan(depot_client, snapshot, root, plan, progress) _download_plan(depot_client, snapshot, root, plan, progress)
@@ -516,8 +688,10 @@ def sync_managed_game(
repair, repair,
False, False,
created=created, created=created,
quarantine=quarantine,
) )
_write_json(paths.native_depot_state, asdict(result)) _write_json(paths.native_depot_state, asdict(result))
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
_emit_progress( _emit_progress(
progress, progress,
"complete", "complete",
@@ -530,6 +704,110 @@ def sync_managed_game(
return result 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( def _download_plan(
client: DepotClient, client: DepotClient,
depot_snapshot: DepotSnapshot, depot_snapshot: DepotSnapshot,
@@ -692,6 +970,7 @@ def _sync_result(
dry_run: bool, dry_run: bool,
*, *,
created: int = 0, created: int = 0,
quarantine: QuarantineResult = QuarantineResult(0, None),
) -> DepotSyncResult: ) -> DepotSyncResult:
return DepotSyncResult( return DepotSyncResult(
region=snapshot.region, region=snapshot.region,
@@ -704,6 +983,8 @@ def _sync_result(
bytes_downloaded=bytes_downloaded, bytes_downloaded=bytes_downloaded,
files_deleted=files_deleted, files_deleted=files_deleted,
files_created=created, files_created=created,
files_quarantined=quarantine.files,
quarantine_directory=quarantine.directory,
repair=repair, repair=repair,
dry_run=dry_run, dry_run=dry_run,
completed_at=datetime.now().astimezone().isoformat(), completed_at=datetime.now().astimezone().isoformat(),
@@ -745,6 +1026,19 @@ def _parse_snapshot(
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths) deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing")) create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing"))
start_options, default_start_option = _parse_start_options(
depot_manifest.get("StartOptions"),
depot_manifest.get("DefaultStartOption"),
)
canonical_file_paths = {file.path.casefold(): file.path for file in files}
start_options = tuple(
DepotStartOption(
name=option.name,
path=canonical_file_paths.get(option.path.casefold(), option.path),
arguments=option.arguments,
)
for option in start_options
)
return DepotSnapshot( return DepotSnapshot(
region=region, region=region,
server=server, server=server,
@@ -754,6 +1048,8 @@ def _parse_snapshot(
files=tuple(files), files=tuple(files),
deleted_files=deleted, deleted_files=deleted,
create_if_missing=create_if_missing, create_if_missing=create_if_missing,
start_options=start_options,
default_start_option=default_start_option,
) )
@@ -798,6 +1094,74 @@ def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]:
return tuple(entries) return tuple(entries)
def _parse_start_options(
value: object,
default_value: object,
) -> tuple[tuple[DepotStartOption, ...], str]:
"""Validate the launch targets published by the official depot manifest."""
# Older test fixtures and historical manifests did not expose StartOptions.
# Babel remains the launcher-safe compatibility default for that case.
if value is None:
return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name
if not isinstance(value, list) or not value:
raise DepotError("StartOptions must be a non-empty array")
options: list[DepotStartOption] = []
seen_names: set[str] = set()
for item in value:
if not isinstance(item, dict):
raise DepotError("StartOptions contains a non-object entry")
name = _start_option_text(item.get("Name"), "StartOptions Name", 64)
folded_name = name.casefold()
if folded_name in seen_names:
raise DepotError(f"StartOptions has duplicate realm name: {name}")
seen_names.add(folded_name)
path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}")
if PurePosixPath(path).suffix.casefold() != ".exe":
raise DepotError(f"StartOptions path is not an executable for {name}: {path}")
arguments = _start_option_text(
item.get("Arguments"),
f"StartOptions arguments for {name}",
2048,
)
options.append(DepotStartOption(name=name, path=path, arguments=arguments))
if default_value is None:
default_name = next(
(
option.name
for option in options
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold()
),
options[0].name,
)
else:
default_name = _start_option_text(
default_value,
"DefaultStartOption",
64,
)
matching_default = next(
(option.name for option in options if option.name.casefold() == default_name.casefold()),
None,
)
if matching_default is None:
raise DepotError(
f"DefaultStartOption does not match an advertised realm: {default_name}"
)
default_name = matching_default
return tuple(options), default_name
def _start_option_text(value: object, field: str, maximum_length: int) -> str:
if not isinstance(value, str):
raise DepotError(f"{field} must be a string")
text = value.strip()
if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text):
raise DepotError(f"invalid {field}")
return text
def _decode_create_if_missing(entry: CreateIfMissing) -> bytes: def _decode_create_if_missing(entry: CreateIfMissing) -> bytes:
try: try:
content = b64decode(entry.content_base64, validate=True) content = b64decode(entry.content_base64, validate=True)

View File

@@ -0,0 +1,59 @@
"""Stable desktop identity shared by the launcher and Wine game window."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
APPLICATION_NAME = "PristonTale EU"
APPLICATION_WM_CLASS = "PristonTaleEU"
APPLICATION_WM_INSTANCE = "pristontale-eu"
APPLICATION_ICON_FILENAME = "eu.pristontale.PTLinux.png"
APPLICATION_ICON_VARIABLE = "PTLINUX_ICON_PATH"
def find_application_icon(
environment: Mapping[str, str] | None = None,
) -> Path | None:
"""Find the installed icon, with source-tree fallbacks for development."""
env = os.environ if environment is None else environment
candidates: list[Path] = []
explicit = env.get(APPLICATION_ICON_VARIABLE)
if explicit:
candidates.append(Path(explicit).expanduser())
# Some distributions bundle the icon beside this module. The installer
# publishes the same asset through the user's XDG data directory.
candidates.append(Path(__file__).with_name(APPLICATION_ICON_FILENAME))
home_value = env.get("HOME")
if home_value:
home = Path(home_value).expanduser()
data_home_value = env.get("XDG_DATA_HOME")
data_home = (
Path(data_home_value).expanduser()
if data_home_value
else home / ".local/share"
)
candidates.append(data_home / "pixmaps" / APPLICATION_ICON_FILENAME)
default_icon = home / ".local/share/pixmaps" / APPLICATION_ICON_FILENAME
if default_icon not in candidates:
candidates.append(default_icon)
# The source checkout keeps installer assets under packaging/.
candidates.append(
Path(__file__).resolve().parent.parent
/ "packaging"
/ APPLICATION_ICON_FILENAME
)
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.resolve(strict=False)
if resolved in seen:
continue
seen.add(resolved)
if resolved.is_file():
return resolved
return None

View File

@@ -11,6 +11,12 @@ import shutil
from typing import Mapping from typing import Mapping
from .launcher import find_wine_tools from .launcher import find_wine_tools
from .runtime import find_steam_proton7
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -70,8 +76,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
ToolStatus( ToolStatus(
"gamescope", "gamescope",
shutil.which("gamescope"), shutil.which("gamescope"),
True, False,
"borderless X11/Wayland game container", "optional manual game-container diagnostics",
), ),
ToolStatus( ToolStatus(
"cabextract", "cabextract",
@@ -88,8 +94,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
ToolStatus( ToolStatus(
"umu-run", "umu-run",
shutil.which("umu-run"), shutil.which("umu-run"),
False, distribution in ARCH_FAMILY_DISTRIBUTIONS,
"portable Proton runtime when Steam Proton 7 is unavailable", "current portable Proton runtime; automatic on Arch-family hosts",
), ),
ToolStatus( ToolStatus(
"vulkaninfo", "vulkaninfo",
@@ -98,6 +104,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
"Vulkan diagnostics", "Vulkan diagnostics",
), ),
] ]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = [] warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}: if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.") warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
@@ -107,6 +114,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
warnings.append( warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0." "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) required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport( return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings), ok=required_ok and not any("No game runtime" in warning for warning in warnings),
@@ -147,38 +155,78 @@ def _python_tk_path() -> str | None:
def _package_hint(distribution: str) -> str | None: def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}: if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return ( 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." "Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
) )
if distribution in {"fedora", "nobara"}: if distribution in {"fedora", "nobara"}:
return ( 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." "and the matching 32-bit Vulkan driver with DNF."
) )
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}: if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return ( return (
"Install python, tk, wine, winetricks, gamescope, cabextract, openssl, and " "Install python, tk, wine, winetricks, umu-launcher, cabextract, "
"the matching lib32 Vulkan driver with Pacman." "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
) )
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return ( return (
"Install Python/Tk, Wine, Winetricks, Gamescope, Cabextract, OpenSSL, and " "Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper." "32-bit Vulkan support with Zypper."
) )
return None return None
def _has_steam_proton7(environment: Mapping[str, str]) -> bool: def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
home_value = environment.get("HOME") return find_steam_proton7(environment) is not None
if not home_value:
return False
home = Path(home_value) def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
for steam_root in ( if architecture.casefold() not in {"x86_64", "amd64"}:
home / ".local/share/Steam", return []
home / ".steam/steam", if distribution in ARCH_FAMILY_DISTRIBUTIONS:
home / "snap/steam/common/.local/share/Steam", return [
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", _optional_tool_status(
): "lib32-vulkan",
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file(): "/usr/lib32/libvulkan.so.1",
return True "32-bit Vulkan runtime compatibility layer",
return False ),
_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()

View File

@@ -23,5 +23,7 @@ def build_gamebox_command(
] ]
if settings.borderless: if settings.borderless:
command.append("--borderless") command.append("--borderless")
if settings.suppress_unresponsive_warnings:
command.append("--suppress-wm-ping")
command.extend(("--", *[str(value) for value in game_command])) command.extend(("--", *[str(value) for value in game_command]))
return command return command

View File

@@ -10,8 +10,13 @@ import sys
import time import time
from collections.abc import Collection, Iterable, Sequence from collections.abc import Collection, Iterable, Sequence
from .desktop_identity import (
APPLICATION_NAME,
APPLICATION_WM_CLASS,
APPLICATION_WM_INSTANCE,
)
WINDOW_TITLE = "PristonTale EU" WINDOW_TITLE = APPLICATION_NAME
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5 WINDOW_READY_DELAY_SECONDS = 0.5
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
@@ -44,6 +49,14 @@ def _select_game_window(
return None 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): class GameboxRuntimeError(RuntimeError):
"""Raised when the game command or X11 mapper cannot be started.""" """Raised when the game command or X11 mapper cannot be started."""
@@ -58,6 +71,13 @@ class _MotifWmHints(ctypes.Structure):
] ]
class _XClassHint(ctypes.Structure):
_fields_ = [
("res_name", ctypes.c_char_p),
("res_class", ctypes.c_char_p),
]
class _X11: class _X11:
def __init__(self) -> None: def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6" library_path = ctypes.util.find_library("X11") or "libX11.so.6"
@@ -127,6 +147,85 @@ class _X11:
self.library.XMapRaised(self.display, window) self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display) self.library.XFlush(self.display)
def set_window_identity(
self,
window: int,
*,
resource_name: str = APPLICATION_WM_INSTANCE,
resource_class: str = APPLICATION_WM_CLASS,
) -> None:
"""Replace Proton's generic steamapp identity before taskbar matching."""
for label, value in (
("resource name", resource_name),
("resource class", resource_class),
):
if not value or "\x00" in value:
raise GameboxRuntimeError(f"invalid X11 {label}")
hints = _XClassHint(
res_name=resource_name.encode("utf-8"),
res_class=resource_class.encode("utf-8"),
)
if self.library.XSetClassHint(
self.display,
window,
ctypes.byref(hints),
) == 0:
raise GameboxRuntimeError("could not set the game window identity")
self.library.XSync(self.display, 0)
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: def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0) atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
if atom == 0: if atom == 0:
@@ -205,8 +304,30 @@ class _X11:
ctypes.c_int, ctypes.c_int,
] ]
self.library.XChangeProperty.restype = ctypes.c_int self.library.XChangeProperty.restype = ctypes.c_int
self.library.XSetClassHint.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(_XClassHint),
]
self.library.XSetClassHint.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.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int 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.argtypes = [ctypes.c_void_p]
self.library.XFlush.restype = ctypes.c_int self.library.XFlush.restype = ctypes.c_int
self.library.XFree.argtypes = [ctypes.c_void_p] self.library.XFree.argtypes = [ctypes.c_void_p]
@@ -215,11 +336,58 @@ class _X11:
self.library.XCloseDisplay.restype = ctypes.c_int 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 _apply_game_window_identity(mapper: _X11, window: int) -> None:
try:
mapper.set_window_identity(window)
except Exception as exc:
print(
f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
def run_game_window( def run_game_window(
command: Sequence[str], command: Sequence[str],
window_title: str = WINDOW_TITLE, window_title: str = WINDOW_TITLE,
*, *,
borderless: bool = False, borderless: bool = False,
suppress_wm_ping: bool = False,
) -> int: ) -> int:
if not command: if not command:
raise GameboxRuntimeError("game command cannot be empty") raise GameboxRuntimeError("game command cannot be empty")
@@ -260,6 +428,7 @@ def run_game_window(
game_window = window game_window = window
game_title = title game_title = title
game_window_created_at = now game_window_created_at = now
_apply_game_window_identity(mapper, window)
print( print(
f"Game window created; preparing it: {title}", f"Game window created; preparing it: {title}",
file=sys.stderr, file=sys.stderr,
@@ -270,7 +439,12 @@ def run_game_window(
and game_window_created_at is not None and game_window_created_at is not None
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS 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( print(
f"Game window ready: {game_title or window_title}", f"Game window ready: {game_title or window_title}",
file=sys.stderr, file=sys.stderr,
@@ -285,7 +459,12 @@ def run_game_window(
flush=True, flush=True,
) )
if game_window is not None: 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()) return int(process.wait())
finally: finally:
mapper.close() mapper.close()
@@ -295,6 +474,7 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--window-title", default=WINDOW_TITLE) parser.add_argument("--window-title", default=WINDOW_TITLE)
parser.add_argument("--borderless", action="store_true") parser.add_argument("--borderless", action="store_true")
parser.add_argument("--suppress-wm-ping", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER) parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv) arguments = parser.parse_args(argv)
command = arguments.command command = arguments.command
@@ -305,6 +485,7 @@ def main(argv: Sequence[str] | None = None) -> int:
command, command,
arguments.window_title, arguments.window_title,
borderless=arguments.borderless, borderless=arguments.borderless,
suppress_wm_ping=arguments.suppress_wm_ping,
) )
except (GameboxRuntimeError, OSError) as exc: except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr) print(f"Error: {exc}", file=sys.stderr)

393
ptlinux/identity_probe.py Normal file
View File

@@ -0,0 +1,393 @@
"""Privacy-safe checks for the Windows identity inputs used by the game client."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
from pathlib import Path
import re
from .command import CommandRunner
from .paths import AppPaths
from .runtime import RuntimeSelection, run_runtime_prefix_command
IDENTITY_PROBE_SHA256 = "677cb5aa19f8dac65b982462bb30974feed05399fdd759765c3b6e802d4c838b"
IDENTITY_PROBE_TIMEOUT_SECONDS = 45.0
IDENTITY_PROBE_PRIVACY = (
"Only success flags, row counts, value lengths, VARIANT types, adapter type, "
"and generic-value quality flags are collected. Computer names, serial numbers, "
"MAC addresses, and WMI values never leave the Windows helper."
)
WMI_PROPERTY_NAMES = (
"processor_id",
"processor_unique_id",
"processor_manufacturer",
"processor_max_clock_speed",
"bios_release_date",
"bios_serial_number",
"bios_identification_code",
"bios_smbios_version",
"bios_manufacturer",
"baseboard_serial_number",
"baseboard_name",
"baseboard_manufacturer",
"baseboard_model",
"video_name",
"video_driver_version",
)
HRESULT_PATTERN = re.compile(r"0x[0-9A-F]{8}\Z")
@dataclass(frozen=True)
class IdentityProbeResult:
helper: str
helper_sha256: str | None
helper_verified: bool
ran: bool
ready: bool | None
error: str | None
report: dict[str, object] | None
observations: tuple[str, ...]
warnings: tuple[str, ...]
def packaged_identity_probe() -> Path:
return Path(__file__).resolve().parent / "native" / "pt-identity-probe.exe"
def probe_client_identity(
paths: AppPaths,
selection: RuntimeSelection,
runner: CommandRunner,
*,
timeout_seconds: float = IDENTITY_PROBE_TIMEOUT_SECONDS,
) -> IdentityProbeResult:
"""Check identity API availability without returning the underlying values."""
helper = packaged_identity_probe()
helper_sha256 = _sha256_if_file(helper)
helper_verified = helper_sha256 == IDENTITY_PROBE_SHA256
if not helper_verified:
error = (
"the packaged privacy-safe identity helper is missing"
if helper_sha256 is None
else "the packaged privacy-safe identity helper failed verification"
)
return IdentityProbeResult(
helper=str(helper),
helper_sha256=helper_sha256,
helper_verified=False,
ran=False,
ready=None,
error=error,
report=None,
observations=(),
warnings=(error,),
)
try:
outcome = run_runtime_prefix_command(
paths,
selection,
runner,
[str(helper)],
check=False,
timeout_seconds=timeout_seconds,
)
except (OSError, RuntimeError) as exc:
error = f"the client-identity helper could not start: {exc}"
return IdentityProbeResult(
helper=str(helper),
helper_sha256=helper_sha256,
helper_verified=True,
ran=False,
ready=None,
error=error,
report=None,
observations=(),
warnings=(error,),
)
if outcome.timed_out:
error = "the client-identity helper timed out"
return _failed_result(helper, helper_sha256, error, ran=True)
if outcome.returncode != 0:
status = "unknown" if outcome.returncode is None else str(outcome.returncode)
error = f"the client-identity helper exited with status {status}"
return _failed_result(helper, helper_sha256, error, ran=True)
try:
report = _parse_privacy_safe_report(outcome.stdout)
except (ValueError, json.JSONDecodeError) as exc:
error = f"the client-identity helper returned invalid data: {exc}"
return _failed_result(helper, helper_sha256, error, ran=True)
warnings = _identity_warnings(report)
return IdentityProbeResult(
helper=str(helper),
helper_sha256=helper_sha256,
helper_verified=True,
ran=True,
ready=not warnings,
error=None,
report=report,
observations=_identity_observations(report),
warnings=warnings,
)
def _failed_result(
helper: Path,
helper_sha256: str | None,
error: str,
*,
ran: bool,
) -> IdentityProbeResult:
return IdentityProbeResult(
helper=str(helper),
helper_sha256=helper_sha256,
helper_verified=True,
ran=ran,
ready=None,
error=error,
report=None,
observations=(),
warnings=(error,),
)
def _parse_privacy_safe_report(stdout: str) -> dict[str, object]:
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
if not lines:
raise ValueError("empty output")
value = json.loads(lines[-1])
root = _object(value, "root")
if _integer(root.get("schema"), "schema") != 2:
raise ValueError("unsupported schema")
computer = _object(root.get("computer_name"), "computer_name")
volume = _object(root.get("volume"), "volume")
adapters = _object(root.get("adapters"), "adapters")
firmware = _object(root.get("firmware"), "firmware")
wmi = _object(root.get("wmi"), "wmi")
properties = _object(wmi.get("properties"), "wmi.properties")
safe_properties: dict[str, object] = {}
for name in WMI_PROPERTY_NAMES:
entry = _object(properties.get(name), f"wmi.properties.{name}")
safe_properties[name] = {
"result": _hresult(entry.get("result"), f"{name}.result"),
"variant_type": _integer(entry.get("variant_type"), f"{name}.variant_type"),
"rows": _integer(entry.get("rows"), f"{name}.rows"),
"nonempty_rows": _integer(
entry.get("nonempty_rows"),
f"{name}.nonempty_rows",
),
"maximum_length": _integer(
entry.get("maximum_length"),
f"{name}.maximum_length",
),
"placeholder_rows": _integer(
entry.get("placeholder_rows"),
f"{name}.placeholder_rows",
),
"wine_rows": _integer(
entry.get("wine_rows"),
f"{name}.wine_rows",
),
}
return {
"schema": 2,
"computer_name": {
"success": _boolean(computer.get("success"), "computer_name.success"),
"nonempty": _boolean(computer.get("nonempty"), "computer_name.nonempty"),
"length": _integer(computer.get("length"), "computer_name.length"),
},
"volume": {
"success": _boolean(volume.get("success"), "volume.success"),
"serial_nonzero": _boolean(
volume.get("serial_nonzero"),
"volume.serial_nonzero",
),
},
"adapters": {
"status": _integer(adapters.get("status"), "adapters.status"),
"count": _integer(adapters.get("count"), "adapters.count"),
"first_address_length": _integer(
adapters.get("first_address_length"),
"adapters.first_address_length",
),
"first_address_nonzero": _boolean(
adapters.get("first_address_nonzero"),
"adapters.first_address_nonzero",
),
"first_address_locally_administered": _boolean(
adapters.get("first_address_locally_administered"),
"adapters.first_address_locally_administered",
),
"first_address_multicast": _boolean(
adapters.get("first_address_multicast"),
"adapters.first_address_multicast",
),
"first_type": _integer(adapters.get("first_type"), "adapters.first_type"),
},
"firmware": {
"raw_smbios_bytes": _integer(
firmware.get("raw_smbios_bytes"),
"firmware.raw_smbios_bytes",
),
},
"wmi": {
"com_initialize": _hresult(wmi.get("com_initialize"), "wmi.com_initialize"),
"com_security": _hresult(wmi.get("com_security"), "wmi.com_security"),
"connect": _hresult(wmi.get("connect"), "wmi.connect"),
"properties": safe_properties,
},
}
def _identity_warnings(report: dict[str, object]) -> tuple[str, ...]:
warnings: list[str] = []
computer = _object(report["computer_name"], "computer_name")
volume = _object(report["volume"], "volume")
adapters = _object(report["adapters"], "adapters")
wmi = _object(report["wmi"], "wmi")
properties = _object(wmi["properties"], "wmi.properties")
computer_success = _boolean(computer["success"], "computer_name.success")
computer_nonempty = _boolean(computer["nonempty"], "computer_name.nonempty")
volume_success = _boolean(volume["success"], "volume.success")
volume_serial_nonzero = _boolean(volume["serial_nonzero"], "volume.serial_nonzero")
adapter_status = _integer(adapters["status"], "adapters.status")
adapter_count = _integer(adapters["count"], "adapters.count")
first_address_length = _integer(
adapters["first_address_length"],
"adapters.first_address_length",
)
first_address_nonzero = _boolean(
adapters["first_address_nonzero"],
"adapters.first_address_nonzero",
)
if not computer_success or not computer_nonempty:
warnings.append("Wine did not provide the computer name used by the login request.")
if not volume_success or not volume_serial_nonzero:
warnings.append("Wine did not provide a usable C: volume identity.")
if (
adapter_status != 0
or adapter_count == 0
or first_address_length < 6
or not first_address_nonzero
):
warnings.append(
"Wine did not provide a usable first network-adapter identity; the game "
"would send its UUID fallback instead."
)
if _hresult(wmi["connect"], "wmi.connect") != "0x00000000":
warnings.append("Wine could not connect to ROOT\\CIMV2 for the login identity query.")
else:
if not _any_nonempty(properties, ("processor_id", "processor_unique_id")):
warnings.append("Wine returned no processor identifier through WMI.")
if not _any_nonempty(
properties,
(
"bios_release_date",
"bios_serial_number",
"bios_identification_code",
"bios_smbios_version",
"bios_manufacturer",
),
):
warnings.append("Wine returned no BIOS identity fields through WMI.")
if not _any_nonempty(
properties,
(
"baseboard_serial_number",
"baseboard_name",
"baseboard_manufacturer",
"baseboard_model",
),
):
warnings.append("Wine returned no baseboard identity fields through WMI.")
if not _any_nonempty(properties, ("video_name",)):
warnings.append("Wine returned no video-controller name through WMI.")
return tuple(warnings)
def _identity_observations(report: dict[str, object]) -> tuple[str, ...]:
observations: list[str] = []
adapters = _object(report["adapters"], "adapters")
if _boolean(
adapters["first_address_locally_administered"],
"adapters.first_address_locally_administered",
):
observations.append(
"The first network-adapter address is locally administered."
)
if _boolean(
adapters["first_address_multicast"],
"adapters.first_address_multicast",
):
observations.append("The first network-adapter address has the multicast bit set.")
wmi = _object(report["wmi"], "wmi")
properties = _object(wmi["properties"], "wmi.properties")
placeholder_names: list[str] = []
wine_names: list[str] = []
for name in WMI_PROPERTY_NAMES:
entry = _object(properties[name], name)
if _integer(entry["placeholder_rows"], f"{name}.placeholder_rows") > 0:
placeholder_names.append(name)
if _integer(entry["wine_rows"], f"{name}.wine_rows") > 0:
wine_names.append(name)
if placeholder_names:
observations.append(
"Common placeholder WMI values were observed in: "
+ ", ".join(placeholder_names)
)
if wine_names:
observations.append(
"Wine-labelled WMI values were observed in: " + ", ".join(wine_names)
)
return tuple(observations)
def _any_nonempty(properties: dict[str, object], names: tuple[str, ...]) -> bool:
for name in names:
entry = _object(properties[name], name)
if (
_hresult(entry["result"], f"{name}.result") == "0x00000000"
and _integer(entry["nonempty_rows"], f"{name}.nonempty_rows") > 0
):
return True
return False
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
raise ValueError(f"{label} must be an object")
return value
def _boolean(value: object, label: str) -> bool:
if not isinstance(value, bool):
raise ValueError(f"{label} must be a boolean")
return value
def _integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 0xFFFFFFFF:
raise ValueError(f"{label} must be an unsigned 32-bit integer")
return value
def _hresult(value: object, label: str) -> str:
if not isinstance(value, str) or HRESULT_PATTERN.fullmatch(value) is None:
raise ValueError(f"{label} must be a hexadecimal HRESULT")
return value
def _sha256_if_file(path: Path) -> str | None:
try:
if not path.is_file():
return None
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()
except OSError:
return None

85
ptlinux/import_state.py Normal file
View File

@@ -0,0 +1,85 @@
"""Persistent trust state for game trees seeded from another installation."""
from __future__ import annotations
from datetime import datetime
import json
import os
from pathlib import Path
from uuid import uuid4
from .paths import AppPaths
IMPORT_STATE_SCHEMA = 2
def game_import_requires_verification(paths: AppPaths) -> bool:
"""Return whether a managed imported tree still needs a full depot repair."""
manifest = paths.game_import_manifest
if not manifest.exists():
return False
try:
value = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return True
if not isinstance(value, dict):
return True
build = value.get("verification_build")
completed_at = value.get("verification_completed_at")
return not (
value.get("verification_required") is False
and isinstance(build, int)
and not isinstance(build, bool)
and build > 0
and isinstance(completed_at, str)
and bool(completed_at.strip())
)
def mark_game_import_verified(
paths: AppPaths,
*,
build: int,
files_quarantined: int,
quarantine_directory: str | None,
) -> None:
"""Record a completed full-manifest verification of an imported tree."""
if isinstance(build, bool) or build <= 0:
raise ValueError("verified import build must be positive")
if files_quarantined < 0:
raise ValueError("quarantined file count cannot be negative")
try:
value = json.loads(paths.game_import_manifest.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
value = {}
if not isinstance(value, dict):
value = {}
value.update(
{
"schema": IMPORT_STATE_SCHEMA,
"verification_required": False,
"verification_build": build,
"verification_completed_at": datetime.now().astimezone().isoformat(),
"files_quarantined": files_quarantined,
"quarantine_directory": quarantine_directory,
}
)
_write_json(paths.game_import_manifest, value)
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()}.{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

View File

@@ -9,6 +9,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import stat
from typing import Mapping from typing import Mapping
import uuid import uuid
@@ -43,6 +44,7 @@ class GameImportResult:
installed_build: int | None installed_build: int | None
started_at: str started_at: str
completed_at: str completed_at: str
verification_required: bool
def import_existing_game( def import_existing_game(
@@ -87,10 +89,13 @@ def import_existing_game(
), ),
started_at=started_at, started_at=started_at,
completed_at=started_at, completed_at=started_at,
verification_required=True,
) )
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}") temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
committed = False
pending_recorded = False
try: try:
temporary.mkdir(parents=False, exist_ok=False) temporary.mkdir(parents=False, exist_ok=False)
runner.run( runner.run(
@@ -111,8 +116,31 @@ def import_existing_game(
): ):
cache_compatibility_binaries(paths, source) cache_compatibility_binaries(paths, source)
temporary.replace(destination) 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: except BaseException:
_remove_staging_tree(temporary, destination.parent) _remove_staging_tree(temporary, destination.parent)
if committed and not pending_recorded:
_remove_staging_tree(destination, destination.parent)
raise raise
metadata_destination = None metadata_destination = None
@@ -140,8 +168,9 @@ def import_existing_game(
), ),
started_at=started_at, started_at=started_at,
completed_at=datetime.now().astimezone().isoformat(), completed_at=datetime.now().astimezone().isoformat(),
verification_required=True,
) )
_write_json(paths.game_import_manifest, asdict(result)) _write_import_manifest(paths, result)
return result return result
@@ -202,22 +231,48 @@ def inventory_game_tree(source_game: Path) -> GameTreeInventory:
source = source_game.expanduser().resolve(strict=True) source = source_game.expanduser().resolve(strict=True)
for relative in REQUIRED_GAME_FILES: for relative in REQUIRED_GAME_FILES:
path = source / relative path = source / relative
if not path.is_file(): try:
status = path.lstat()
except OSError:
raise GameImportError(f"game installation is incomplete: missing {relative}") 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 files = 0
total_bytes = 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) 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: for filename in filenames:
path = root_path / filename path = root_path / filename
if path.is_symlink():
files += 1
continue
try: try:
total_bytes += path.stat().st_size status = path.lstat()
except OSError as exc: except OSError as exc:
raise GameImportError(f"cannot inventory game file {path}: {exc}") from 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 files += 1
return GameTreeInventory( return GameTreeInventory(
files=files, files=files,
@@ -327,6 +382,21 @@ def _sha256(path: Path) -> str:
def _write_json(path: Path, value: object) -> None: def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") try:
temporary.replace(path) 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)},
)

View File

@@ -7,6 +7,7 @@ from enum import Enum
import json import json
from pathlib import Path from pathlib import Path
from .import_state import game_import_requires_verification
from .paths import AppPaths from .paths import AppPaths
@@ -24,6 +25,8 @@ class PrimaryAction(str, Enum):
INSTALL = "INSTALL" INSTALL = "INSTALL"
UPDATE = "UPDATE" UPDATE = "UPDATE"
PLAY = "PLAY" PLAY = "PLAY"
CHECKING = "CHECKING…"
LAUNCHER_UPDATE = "UPDATE LAUNCHER"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -40,8 +43,10 @@ class GameInstallationStatus:
def inspect_game_installation( def inspect_game_installation(
paths: AppPaths, paths: AppPaths,
official_build: int | None = None, official_build: int | None = None,
*,
compatibility_ready: bool | None = True,
) -> GameInstallationStatus: ) -> 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. 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 When the official build cannot be checked, a complete local game remains
@@ -54,6 +59,8 @@ def inspect_game_installation(
or official_build < 0 or official_build < 0
): ):
raise ValueError("official build must be a non-negative integer") 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( missing_files = tuple(
relative relative
@@ -64,10 +71,16 @@ def inspect_game_installation(
local_build = _read_verified_local_build(paths.native_depot_state) local_build = _read_verified_local_build(paths.native_depot_state)
if not installed: if not installed:
action = PrimaryAction.INSTALL action = PrimaryAction.INSTALL
elif game_import_requires_verification(paths):
action = PrimaryAction.UPDATE
elif official_build is not None and ( elif official_build is not None and (
local_build is None or local_build < official_build local_build is None or local_build < official_build
): ):
action = PrimaryAction.UPDATE action = PrimaryAction.UPDATE
elif compatibility_ready is None:
action = PrimaryAction.CHECKING
elif not compatibility_ready:
action = PrimaryAction.LAUNCHER_UPDATE
else: else:
action = PrimaryAction.PLAY action = PrimaryAction.PLAY
return GameInstallationStatus( return GameInstallationStatus(

View File

@@ -7,13 +7,51 @@ from datetime import datetime
import fcntl import fcntl
import os import os
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator, TextIO
class OperationBusyError(RuntimeError): class OperationBusyError(RuntimeError):
"""Raised when another launcher-prefix operation is still active.""" """Raised when another launcher-prefix operation is still active."""
class LauncherPlayLease:
"""Exclusive preparation lease that can become a shared game lease."""
def __init__(
self,
handle: TextIO | None,
*,
preparation_allowed: bool,
record_owned: bool,
) -> None:
self._handle = handle
self.preparation_allowed = preparation_allowed
self._record_owned = record_owned
self._shared = not preparation_allowed
@classmethod
def dry_run(cls) -> LauncherPlayLease:
return cls(None, preparation_allowed=True, record_owned=False)
@property
def shared(self) -> bool:
return self._shared
def allow_shared_clients(self) -> None:
"""Downgrade after all runtime mutations have completed."""
if self._shared:
return
if self._handle is not None:
_write_owner(self._handle, "play")
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
self._shared = True
@property
def record_owned(self) -> bool:
return self._record_owned
@contextmanager @contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -24,15 +62,9 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.seek(0) handle.seek(0)
owner = handle.read().strip() or "unknown operation" owner = handle.read().strip() or "unknown operation"
raise OperationBusyError( 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 ) from exc
handle.seek(0) _write_owner(handle, operation)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
try: try:
yield yield
finally: finally:
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.truncate() handle.truncate()
handle.flush() handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN) fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@contextmanager
def launcher_play_lock(
lock_path: Path,
operation: str = "play",
) -> Iterator[LauncherPlayLease]:
"""Serialize preparation, then allow multiple active game clients.
The first play process acquires an exclusive lease so it may safely rebuild
the runtime view or prefix. It downgrades that lease immediately before
starting Game.exe. Later play processes then receive shared, read-only leases,
while updates and repairs remain excluded until every client exits.
"""
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError as exc:
owner = _read_owner(handle)
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
lease = LauncherPlayLease(
handle,
preparation_allowed=False,
record_owned=False,
)
else:
_write_owner(handle, f"{operation} preparation")
lease = LauncherPlayLease(
handle,
preparation_allowed=True,
record_owned=True,
)
try:
yield lease
finally:
# A downgraded owner record remains useful while any secondary shared
# lease is active. The next exclusive operation atomically replaces it.
if lease.record_owned and not lease.shared:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _read_owner(handle: TextIO) -> str:
handle.seek(0)
return handle.read().strip() or "unknown operation"
def _write_owner(handle: TextIO, operation: str) -> None:
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()

View 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
View 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)

Binary file not shown.

View File

@@ -20,6 +20,8 @@ OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_sma
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts" BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
MAX_NEWS_BYTES = 2 * 1024 * 1024 MAX_NEWS_BYTES = 2 * 1024 * 1024
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024 MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
MAX_BROADCAST_AUTHOR_CHARACTERS = 100
MAX_BROADCAST_CONTENT_CHARACTERS = 2000
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -153,7 +155,13 @@ def fetch_news(
_write_cache(cache_path, articles) _write_cache(cache_path, articles)
return articles return articles
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError): 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( def fetch_latest_broadcast(
@@ -179,7 +187,13 @@ def fetch_latest_broadcast(
_write_broadcast_cache(cache_path, broadcast) _write_broadcast_cache(cache_path, broadcast)
return broadcast return broadcast
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError): 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, ...]: def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
@@ -229,26 +243,41 @@ def _parse_latest_broadcast(payload: bytes) -> Broadcast:
value = json.loads(payload.decode("utf-8-sig")) value = json.loads(payload.decode("utf-8-sig"))
if not isinstance(value, list) or not value or not isinstance(value[0], dict): if not isinstance(value, list) or not value or not isinstance(value[0], dict):
raise NewsError("broadcast response does not contain a latest broadcast") raise NewsError("broadcast response does not contain a latest broadcast")
latest = value[0] return _broadcast_from_mapping(value[0])
author = latest.get("author")
content = latest.get("content")
def _broadcast_from_mapping(latest: dict[object, object]) -> Broadcast:
author = _bounded_text(latest.get("author"), MAX_BROADCAST_AUTHOR_CHARACTERS)
content = _bounded_text(latest.get("content"), MAX_BROADCAST_CONTENT_CHARACTERS)
url = _broadcast_url(latest.get("url")) url = _broadcast_url(latest.get("url"))
if ( if (
not isinstance(author, str) author is None
or not author.strip() or content is None
or not isinstance(content, str)
or not content.strip()
or url is None or url is None
): ):
raise NewsError("latest broadcast is incomplete") raise NewsError("latest broadcast is incomplete")
published_at = (
_format_timestamp(latest.get("timestamp"))
if "timestamp" in latest
else _bounded_text(latest.get("published_at"), 64)
)
return Broadcast( return Broadcast(
author=author.strip(), author=author,
content=content.strip(), content=content,
published_at=_format_timestamp(latest.get("timestamp")), published_at=published_at,
url=url, url=url,
) )
def _bounded_text(value: object, maximum_characters: int) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text or len(text) > maximum_characters:
return None
return text
def _format_timestamp(value: object) -> str | None: def _format_timestamp(value: object) -> str | None:
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, (int, float)):
return None return None
@@ -276,10 +305,16 @@ def _image_url(value: object) -> str | None:
def _broadcast_url(value: object) -> str | None: def _broadcast_url(value: object) -> str | None:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return None return None
parsed = urlparse(value) url = value.strip()
if parsed.scheme not in {"http", "https"} or not parsed.netloc: parsed = urlparse(url)
if (
parsed.scheme.casefold() != "https"
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
):
return None return None
return value.strip() return url
def _read_cache(path: Path) -> tuple[NewsArticle, ...]: def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
@@ -288,7 +323,7 @@ def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
if not isinstance(value, list): if not isinstance(value, list):
return () return ()
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict)) return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
except (OSError, TypeError, json.JSONDecodeError): except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
return () return ()
@@ -308,8 +343,8 @@ def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
def _read_broadcast_cache(path: Path) -> Broadcast | None: def _read_broadcast_cache(path: Path) -> Broadcast | None:
try: try:
value = json.loads(path.read_text(encoding="utf-8")) value = json.loads(path.read_text(encoding="utf-8"))
return Broadcast(**value) if isinstance(value, dict) else None return _broadcast_from_mapping(value) if isinstance(value, dict) else None
except (OSError, TypeError, json.JSONDecodeError): except (OSError, UnicodeError, NewsError, json.JSONDecodeError):
return None return None

View File

@@ -19,7 +19,6 @@ from ptdiag.assbin import (
) )
from .paths import AppPaths from .paths import AppPaths
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug") WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
@@ -42,6 +41,10 @@ CURRENT_V21_BINARY_HASHES = {
PATCHED_V21_GAME_DLL_SHA256 = ( PATCHED_V21_GAME_DLL_SHA256 = (
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
) )
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
class OverlayError(RuntimeError): class OverlayError(RuntimeError):
@@ -72,11 +75,59 @@ class CompatibilityCacheResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class RuntimePatchCacheResult: class RuntimeCompatibility:
source: str """Read-only compatibility result for the managed game currently on disk."""
destination: str
game_dll_sha256: str compatible: bool
created_at: str 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( def prepare_runtime_view(
@@ -91,17 +142,36 @@ def prepare_runtime_view(
source_scene = source / WHISPERING_VALE_RELATIVE source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene) inspection = inspect_wrapped_assbin(source_scene)
action = _scene_action(inspection) 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, paths,
source, 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}" build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}" backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
links_created = 0 links_created = 0
try: try:
build.mkdir(parents=True, exist_ok=False) build.mkdir(parents=True, exist_ok=False)
top_level_exceptions = {"game"} top_level_exceptions = {"game", "save"}
for name, binary_source in ( for name, binary_source in (
("Game.exe", game_exe_source), ("Game.exe", game_exe_source),
("game.dll", game_dll_source), ("game.dll", game_dll_source),
@@ -113,6 +183,8 @@ def prepare_runtime_view(
build, build,
except_names=top_level_exceptions, 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": if game_exe_source != source / "Game.exe":
(build / "Game.exe").symlink_to(game_exe_source) (build / "Game.exe").symlink_to(game_exe_source)
links_created += 1 links_created += 1
@@ -165,7 +237,13 @@ def prepare_runtime_view(
created_at=datetime.now().astimezone().isoformat(), created_at=datetime.now().astimezone().isoformat(),
links_created=links_created, 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 return result
@@ -202,50 +280,43 @@ def cache_compatibility_binaries(
return result 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( def select_binary_action(
cache_hashes: dict[str, str] | None, cache_hashes: dict[str, str] | None,
source_hashes: dict[str, str], source_hashes: dict[str, str],
runtime_patch_hash: str | None = None,
) -> str: ) -> 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 is not None:
if cache_hashes != COMPATIBILITY_BINARY_HASHES: if cache_hashes != COMPATIBILITY_BINARY_HASHES:
raise OverlayError("compatibility cache has an unknown binary profile") raise OverlayError("compatibility cache has an unknown binary profile")
if source_hashes == COMPATIBILITY_BINARY_HASHES: if source_hashes == COMPATIBILITY_BINARY_HASHES:
return "verified-source-pair" return "verified-source-pair"
if runtime_patch_hash is not None: if source_hashes == CURRENT_V21_BINARY_HASHES:
if source_hashes != CURRENT_V21_BINARY_HASHES: return "verified-v21-publisher-pair"
raise OverlayError("V21 runtime patch does not match the installed client profile") game_exe_sha256 = source_hashes.get("Game.exe")
return "verified-v21-texture-reclaim" 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" return "upstream-pair"
@@ -349,26 +420,287 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
return count 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 = paths.compatibility_binaries
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
source_hashes = _binary_hashes(source) source_hashes = _binary_hashes(source)
patch = paths.runtime_patch_dll action = select_binary_action(cache_hashes, source_hashes)
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)
return ( return (
action, action,
source / "Game.exe", 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]: def _binary_hashes(directory: Path) -> dict[str, str]:
return { return {
name: _sha256(directory / name) 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: def _remove_runtime_tree(path: Path) -> None:
if path.is_symlink() or path.is_file(): if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True) path.unlink(missing_ok=True)

View File

@@ -20,7 +20,13 @@ def _xdg_path(
fallback: Path, fallback: Path,
) -> Path: ) -> Path:
value = environment.get(variable) 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) @dataclass(frozen=True)
@@ -91,6 +97,12 @@ class AppPaths:
def launcher_update_lock(self) -> Path: def launcher_update_lock(self) -> Path:
return self.state / "launcher-update.lock" 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: def launcher_update_archive(self, version: str) -> Path:
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz" return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
@@ -118,6 +130,12 @@ class AppPaths:
def launcher_operation_lock(self) -> Path: def launcher_operation_lock(self) -> Path:
return self.state / "launcher-operation.lock" 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 @property
def source_game(self) -> Path: def source_game(self) -> Path:
return self.launcher_prefix / GAME_RELATIVE_PATH return self.launcher_prefix / GAME_RELATIVE_PATH
@@ -126,16 +144,34 @@ class AppPaths:
def game_import_manifest(self) -> Path: def game_import_manifest(self) -> Path:
return self.state / "game-import.json" 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 @property
def native_depot_state(self) -> Path: def native_depot_state(self) -> Path:
"""Last completed native depot synchronization.""" """Last completed native depot synchronization."""
return self.state / "native-depot.json" return self.state / "native-depot.json"
@property
def depot_launch_catalog_cache(self) -> Path:
"""Last official realm choices used for network-free game launches."""
return self.cache / "depot-launch-catalog.json"
@property @property
def news_cache(self) -> Path: def news_cache(self) -> Path:
return self.cache / "news.json" return self.cache / "news.json"
@property
def patches_cache(self) -> Path:
"""Cached official patch changelog shown beside the news feed."""
return self.cache / "patches.json"
@property @property
def broadcast_cache(self) -> Path: def broadcast_cache(self) -> Path:
"""Cached latest announcement from the broadcast service.""" """Cached latest announcement from the broadcast service."""
@@ -162,6 +198,16 @@ class AppPaths:
def runtime_manifest(self) -> Path: def runtime_manifest(self) -> Path:
return self.state / "runtime-view.json" 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 @property
def compatibility_binaries(self) -> Path: def compatibility_binaries(self) -> Path:
return self.data / "compatibility-binaries" return self.data / "compatibility-binaries"
@@ -172,16 +218,26 @@ class AppPaths:
@property @property
def runtime_patch_directory(self) -> Path: 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 @property
def runtime_patch_dll(self) -> Path: 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" return self.runtime_patch_directory / "game.dll"
@property @property
def runtime_patch_manifest(self) -> Path: def runtime_patch_manifest(self) -> Path:
return self.state / "runtime-patch.json" 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 @property
def compat_data(self) -> Path: def compat_data(self) -> Path:
return self.data / "game-compat" return self.data / "game-compat"
@@ -206,6 +262,10 @@ class AppPaths:
def settings_file(self) -> Path: def settings_file(self) -> Path:
return self.config / "settings.json" return self.config / "settings.json"
@property
def settings_lock(self) -> Path:
return self.config / "settings.lock"
def create_base_directories(self) -> None: def create_base_directories(self) -> None:
for path in ( for path in (
self.data, self.data,

View File

@@ -4,20 +4,26 @@ from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path, PurePosixPath
import platform
import re
import shlex
import shutil import shutil
import sys import sys
from typing import Mapping from typing import Mapping, Sequence
from .command import CommandOutcome, CommandRunner from .command import CommandOutcome, CommandRunner, OutputLineFilter
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
from .gamebox import build_gamebox_command from .gamebox import build_gamebox_command
from .paths import AppPaths from .paths import AppPaths
from .settings import Settings from .settings import Settings
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe" WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe"
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43") NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
DXVK_D3D9_OVERRIDE = "native,builtin" DXVK_D3D9_OVERRIDE = "native,builtin"
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides" DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
@@ -25,6 +31,18 @@ WINETRICKS_CODEPAGE_PROBE = (
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"' 'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
) )
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "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
PROTON7_RELATIVE_PATH = Path("steamapps/common/Proton 7.0/proton")
STEAM_LIBRARY_FOLDERS_RELATIVE_PATH = Path("steamapps/libraryfolders.vdf")
STEAM_LIBRARY_FOLDERS_MAX_BYTES = 4 * 1024 * 1024
_VDF_QUOTED_PAIR = re.compile(
r'"((?:\\.|[^"\\])*)"\s*"((?:\\.|[^"\\])*)"'
)
class RuntimeErrorPT(RuntimeError): class RuntimeErrorPT(RuntimeError):
@@ -42,23 +60,79 @@ class RuntimeSelection:
def select_runtime( def select_runtime(
settings: Settings, settings: Settings,
environment: Mapping[str, str] | None = None, environment: Mapping[str, str] | None = None,
*,
distribution_id: str | None = None,
kernel_release: str | None = None,
) -> RuntimeSelection: ) -> RuntimeSelection:
env = os.environ if environment is None else environment 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() proton = Path(settings.proton_path).expanduser().resolve()
if not proton.is_file(): if not proton.is_file():
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}") raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
steam_root = _steam_root_for_proton(proton) steam_root = _steam_root_for_proton(proton, env)
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)) 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) proton_result = find_steam_proton7(env)
if proton_result is not None: if proton_result is not None:
proton, steam_root = proton_result 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)) 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") raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
if settings.runtime in {"auto", "umu"}: if settings.runtime in {"auto", "umu"}:
umu = shutil.which("umu-run")
if umu: if umu:
return RuntimeSelection("umu", umu, None, None) return RuntimeSelection("umu", umu, None, None)
raise RuntimeErrorPT( raise RuntimeErrorPT(
@@ -70,33 +144,108 @@ def select_runtime(
def find_steam_proton7( def find_steam_proton7(
environment: Mapping[str, str] | None = None, environment: Mapping[str, str] | None = None,
) -> tuple[Path, Path] | None: ) -> tuple[Path, Path] | None:
"""Find Proton 7 in a Steam client root or one of its registered libraries.
The returned Steam root is always the client installation, even when the
Proton tool itself lives in a secondary library. Proton 7 uses that client
root for Steam's bundled media compatibility libraries.
"""
env = os.environ if environment is None else environment env = os.environ if environment is None else environment
home_value = env.get("HOME") for steam_root in _steam_client_roots(env):
if not home_value: for library_root in _steam_library_roots(steam_root):
return None proton = library_root / PROTON7_RELATIVE_PATH
home = Path(home_value).expanduser() if proton.is_file():
roots: list[Path] = [] return proton.resolve(), steam_root
explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") return None
def _steam_client_roots(environment: Mapping[str, str]) -> tuple[Path, ...]:
candidates: list[Path] = []
explicit = environment.get("STEAM_COMPAT_CLIENT_INSTALL_PATH")
if explicit: if explicit:
roots.append(Path(explicit).expanduser()) candidates.append(Path(explicit).expanduser())
roots.extend( home_value = environment.get("HOME")
( if home_value:
home / ".local/share/Steam", home = Path(home_value).expanduser()
home / ".steam/steam", candidates.extend(
home / "snap/steam/common/.local/share/Steam", (
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", home / ".local/share/Steam",
home / ".steam/steam",
home / ".steam/root",
home / ".steam/debian-installation",
home / "snap/steam/common/.local/share/Steam",
home / "snap/steam/common/.steam/steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
)
) )
return _unique_resolved_paths(candidates)
def _steam_library_roots(steam_root: Path) -> tuple[Path, ...]:
registered = _read_steam_library_folders(
steam_root / STEAM_LIBRARY_FOLDERS_RELATIVE_PATH
) )
return _unique_resolved_paths((steam_root, *registered))
def _read_steam_library_folders(path: Path) -> tuple[Path, ...]:
"""Read current and legacy Steam libraryfolders.vdf path records."""
try:
with path.open("rb") as handle:
content = handle.read(STEAM_LIBRARY_FOLDERS_MAX_BYTES + 1)
except OSError:
return ()
if len(content) > STEAM_LIBRARY_FOLDERS_MAX_BYTES:
return ()
text = content.decode("utf-8-sig", errors="replace")
result: list[Path] = []
for raw_key, raw_value in _VDF_QUOTED_PAIR.findall(text):
key = _decode_vdf_string(raw_key)
value = _decode_vdf_string(raw_value)
# Current files use a named "path" property. Older Steam clients used
# numeric top-level keys, which are distinguishable from app-size pairs
# because a Linux library path must be absolute.
if key.casefold() != "path" and not key.isdecimal():
continue
if not value or "\x00" in value:
continue
library = Path(value).expanduser()
if library.is_absolute():
result.append(library)
return tuple(result)
def _decode_vdf_string(value: str) -> str:
result: list[str] = []
index = 0
while index < len(value):
character = value[index]
if character == "\\" and index + 1 < len(value):
next_character = value[index + 1]
if next_character in {'"', "\\"}:
result.append(next_character)
index += 2
continue
result.append(character)
index += 1
return "".join(result)
def _unique_resolved_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
result: list[Path] = []
seen: set[Path] = set() seen: set[Path] = set()
for root in roots: for path in paths:
resolved = root.resolve() try:
resolved = path.resolve()
except (OSError, RuntimeError):
continue
if resolved in seen: if resolved in seen:
continue continue
seen.add(resolved) seen.add(resolved)
proton = resolved / "steamapps/common/Proton 7.0/proton" result.append(resolved)
if proton.is_file(): return tuple(result)
return proton, resolved
return None
def prepare_game_runtime( def prepare_game_runtime(
@@ -117,9 +266,11 @@ def prepare_game_runtime(
timeout_seconds=1800.0, 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: if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection") 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: if not runner.dry_run:
paths.compat_data.mkdir(parents=True, exist_ok=True) paths.compat_data.mkdir(parents=True, exist_ok=True)
proton = Path(selection.proton_path) proton = Path(selection.proton_path)
@@ -198,34 +349,69 @@ def prepare_game_runtime(
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
if not runner.dry_run: if not runner.dry_run:
_write_json( _write_json(
paths.state / "game-runtime.json", paths.game_runtime_marker,
{ {
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
"configured_at": datetime.now().astimezone().isoformat(), "configured_at": datetime.now().astimezone().isoformat(),
"native_d3d43": True, "native_d3d43": True,
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE, "dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
"proton_identity": _proton_identity(selection),
"runtime": asdict(selection), "runtime": asdict(selection),
}, },
) )
return results return results
def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
"""Return whether another client may reuse the prepared runtime read-only."""
if selection.kind == "umu":
return native_d3d_ready(paths.umu_prefix)
if selection.kind in {"proton7", "proton7-container"}:
return _game_runtime_ready(paths, selection)
return False
def run_game( def run_game(
paths: AppPaths, paths: AppPaths,
settings: Settings, settings: Settings,
selection: RuntimeSelection, selection: RuntimeSelection,
runner: CommandRunner, runner: CommandRunner,
*, *,
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
use_gamebox: bool = True, use_gamebox: bool = True,
concurrent_prefix_launch: bool = False,
timeout_seconds: float = 12 * 60 * 60,
check: bool = True,
extra_environment: Mapping[str, str] | None = None,
output_line_filter: OutputLineFilter | None = None,
) -> CommandOutcome: ) -> CommandOutcome:
if not paths.runtime_view.is_dir() and not runner.dry_run: if not paths.runtime_view.is_dir() and not runner.dry_run:
raise RuntimeErrorPT("runtime view is missing; prepare the game first") raise RuntimeErrorPT("runtime view is missing; prepare the game first")
native_executable, windows_executable, game_arguments = _game_start_command(
paths,
start_option,
)
if selection.kind == "umu": if selection.kind == "umu":
game_command = [ game_command = [
selection.executable, selection.executable,
str(paths.runtime_view / "Game.exe"), native_executable,
"PTEU_REBORN", *game_arguments,
] ]
environment = _umu_environment(paths) 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,
native_executable,
*game_arguments,
]
environment = _umu_proton7_environment(
paths,
selection,
synchronization=resolve_proton_sync_mode(settings.proton_sync),
)
else: else:
if not selection.proton_path: if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing") raise RuntimeErrorPT("Proton script path is missing")
@@ -235,12 +421,25 @@ def run_game(
"ptlinux.proton_compat", "ptlinux.proton_compat",
selection.proton_path, selection.proton_path,
"runinprefix", "runinprefix",
WINDOWS_GAME_EXE, windows_executable,
"PTEU_REBORN", *game_arguments,
] ]
environment = _proton_environment(paths, selection) environment = _proton_environment(
paths,
selection,
synchronization=resolve_proton_sync_mode(settings.proton_sync),
)
if selection.kind in {"umu", "proton7-container"}:
# Keep every UMU launch identical except for the verb, as required when
# several games share one prefix. The first client owns prefix startup;
# every later shared-lease client joins the active wineserver directly.
environment["PROTON_VERB"] = (
"runinprefix" if concurrent_prefix_launch else "waitforexitandrun"
)
if use_gamebox: if use_gamebox:
game_command = build_gamebox_command(game_command, settings) game_command = build_gamebox_command(game_command, settings)
if extra_environment:
environment.update(extra_environment)
environment["PYTHONPATH"] = _runtime_python_path( environment["PYTHONPATH"] = _runtime_python_path(
os.environ.get("PYTHONPATH"), os.environ.get("PYTHONPATH"),
) )
@@ -248,8 +447,134 @@ def run_game(
game_command, game_command,
cwd=paths.runtime_view, cwd=paths.runtime_view,
env_overrides=environment, env_overrides=environment,
timeout_seconds=12 * 60 * 60, timeout_seconds=timeout_seconds,
check=check,
stream_output=True, stream_output=True,
output_line_filter=output_line_filter,
)
def _game_start_command(
paths: AppPaths,
option: DepotStartOption,
) -> tuple[str, str, tuple[str, ...]]:
relative = PurePosixPath(option.path)
if (
relative.is_absolute()
or not relative.parts
or any(part in {"", ".", ".."} for part in relative.parts)
or relative.suffix.casefold() != ".exe"
):
raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}")
try:
arguments = tuple(shlex.split(option.arguments))
except ValueError as exc:
raise RuntimeErrorPT(
f"invalid launch arguments for realm {option.name}: {exc}"
) from exc
if not arguments:
raise RuntimeErrorPT(f"realm {option.name} has no launch arguments")
native = str(_resolve_runtime_path_case(paths.runtime_view, relative))
windows_relative = "\\".join(relative.parts)
return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments
def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
"""Match Windows manifest casing against the case-sensitive runtime view."""
direct = root.joinpath(*relative.parts)
if direct.exists():
return direct
cursor = root
for part in relative.parts:
if not cursor.is_dir():
return direct
try:
matches = tuple(
entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold()
)
except OSError:
return direct
if not matches:
return direct
if len(matches) > 1:
raise RuntimeErrorPT(
f"realm executable path has ambiguous casing: {relative.as_posix()}"
)
cursor = matches[0]
return cursor
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 +596,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( def _proton_environment(
paths: AppPaths, paths: AppPaths,
selection: RuntimeSelection, selection: RuntimeSelection,
*,
synchronization: str = RUNTIME_DEFAULT_SYNC,
) -> dict[str, str]: ) -> dict[str, str]:
if not selection.steam_root: if not selection.steam_root:
raise RuntimeErrorPT("Steam root is missing for Proton") raise RuntimeErrorPT("Steam root is missing for Proton")
return { environment = {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root, "STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data), "STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view), "STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
@@ -288,6 +642,90 @@ def _proton_environment(
"PROTON_LOG": "0", "PROTON_LOG": "0",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "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: def _ensure_proton_game_link(paths: AppPaths) -> None:
@@ -305,15 +743,27 @@ def _ensure_proton_game_link(paths: AppPaths) -> None:
link.symlink_to(paths.runtime_view, target_is_directory=True) link.symlink_to(paths.runtime_view, target_is_directory=True)
def _steam_root_for_proton(proton: Path) -> Path: def _steam_root_for_proton(
parts = proton.parts proton: Path,
try: environment: Mapping[str, str] | None = None,
index = parts.index("steamapps") ) -> Path:
except ValueError as exc: steamapps = next(
(parent for parent in proton.parents if parent.name.casefold() == "steamapps"),
None,
)
if steamapps is None:
raise RuntimeErrorPT( raise RuntimeErrorPT(
"configured Proton path must be inside a Steam steamapps directory" "configured Proton path must be inside a Steam steamapps directory"
) from exc )
return Path(*parts[:index]) library_root = steamapps.parent.resolve()
env = os.environ if environment is None else environment
for steam_root in _steam_client_roots(env):
if library_root in _steam_library_roots(steam_root):
return steam_root
# Preserve support for an otherwise valid, manually managed Steam layout.
# Without a registered client root, the library root is the best available
# compatibility value and matches the launcher's historical behaviour.
return library_root
def native_d3d_ready(prefix: Path) -> bool: def native_d3d_ready(prefix: Path) -> bool:
@@ -348,21 +798,23 @@ def prepare_proton7_winetricks(
dry_run: bool = False, dry_run: bool = False,
) -> Path: ) -> Path:
try: try:
content = source.read_text(encoding="utf-8") content = source.read_bytes()
except OSError as exc: except OSError as exc:
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from 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 return source
if dry_run: if dry_run:
return destination return destination
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
patched = content.replace( patched = content.replace(
WINETRICKS_CODEPAGE_PROBE, probe,
WINETRICKS_PROTON7_PROBE, replacement,
1, 1,
) )
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp") 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.chmod(source.stat().st_mode & 0o777)
temporary.replace(destination) temporary.replace(destination)
return destination return destination
@@ -374,6 +826,69 @@ def _proton_prefix_ready(prefix: Path) -> bool:
).exists() ).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: def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"): for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
wine = directory / "wine" wine = directory / "wine"
@@ -388,3 +903,19 @@ def _write_json(path: Path, value: object) -> None:
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") 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.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path) 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()

View File

@@ -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") _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") _SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"} _ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
UPDATE_CHECK_CACHE_SCHEMA = 1
class LauncherUpdateError(RuntimeError): class LauncherUpdateError(RuntimeError):
@@ -84,7 +85,20 @@ class LauncherUpdateResult:
previous_release: str | None previous_release: str | None
archive: str | None archive: str | None
archive_sha256: 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) @dataclass(frozen=True)
@@ -143,6 +157,89 @@ def check_for_launcher_update(
) )
_verify_manifest_signature(manifest_bytes, signature_bytes, key) _verify_manifest_signature(manifest_bytes, signature_bytes, key)
manifest = parse_update_manifest(manifest_bytes) 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") current = _parse_version(current_version, "current launcher version")
latest = _parse_version(manifest.version, "manifest version") latest = _parse_version(manifest.version, "manifest version")
minimum = _parse_version( minimum = _parse_version(
@@ -170,7 +267,7 @@ def check_for_launcher_update(
available=available, available=available,
supported=supported, supported=supported,
reason=reason, reason=reason,
manifest_url=url, manifest_url=manifest_url,
manifest=manifest, manifest=manifest,
) )
@@ -178,6 +275,7 @@ def check_for_launcher_update(
def install_launcher_update( def install_launcher_update(
paths: AppPaths, paths: AppPaths,
*, *,
current_version: str = __version__,
manifest_url: str | None = None, manifest_url: str | None = None,
public_key_path: Path | None = None, public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None, environment: Mapping[str, str] | None = None,
@@ -186,6 +284,7 @@ def install_launcher_update(
"""Authenticate, stage, validate, and atomically activate one update.""" """Authenticate, stage, validate, and atomically activate one update."""
check = check_for_launcher_update( check = check_for_launcher_update(
current_version=current_version,
manifest_url=manifest_url, manifest_url=manifest_url,
public_key_path=public_key_path, public_key_path=public_key_path,
environment=environment, environment=environment,
@@ -200,7 +299,9 @@ def install_launcher_update(
previous_release=None, previous_release=None,
archive=None, archive=None,
archive_sha256=None, archive_sha256=None,
restarted_required=False, restart_required=False,
state_recorded=False,
warning=None,
) )
if not check.supported: if not check.supported:
raise LauncherUpdateError(check.reason) raise LauncherUpdateError(check.reason)
@@ -215,20 +316,26 @@ def install_launcher_update(
) )
release_directory = _stage_and_install_release(paths, archive, check.manifest) release_directory = _stage_and_install_release(paths, archive, check.manifest)
previous = _activate_release(paths, release_directory) previous = _activate_release(paths, release_directory)
_write_json( state_warning = None
paths.launcher_update_state, try:
{ _write_json(
"schema": 1, paths.launcher_update_state,
"updated_at": datetime.now().astimezone().isoformat(), {
"from_version": check.current_version, "schema": 1,
"to_version": check.manifest.version, "updated_at": datetime.now().astimezone().isoformat(),
"manifest_url": check.manifest_url, "from_version": check.current_version,
"archive_url": check.manifest.archive_url, "to_version": check.manifest.version,
"archive_sha256": check.manifest.archive_sha256, "manifest_url": check.manifest_url,
"release_directory": str(release_directory), "archive_url": check.manifest.archive_url,
"previous_release": str(previous) if previous else None, "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( return LauncherUpdateResult(
changed=True, changed=True,
current_version=check.current_version, current_version=check.current_version,
@@ -237,7 +344,57 @@ def install_launcher_update(
previous_release=str(previous) if previous else None, previous_release=str(previous) if previous else None,
archive=str(archive), archive=str(archive),
archive_sha256=check.manifest.archive_sha256, 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" f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
) )
published_at = _required_string(value, "published_at") published_at = _required_string(value, "published_at")
_parse_published_at(published_at)
notes_value = value.get("notes_url") notes_value = value.get("notes_url")
if notes_value is not None and not isinstance(notes_value, str): if notes_value is not None and not isinstance(notes_value, str):
raise LauncherUpdateError("notes_url must be a string or null") raise LauncherUpdateError("notes_url must be a string or null")
@@ -344,6 +502,8 @@ def _fetch_small_file(
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
"Accept": "application/octet-stream, application/json", "Accept": "application/octet-stream, application/json",
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}, },
method="GET", method="GET",
) )
@@ -354,7 +514,13 @@ def _fetch_small_file(
except (URLError, OSError) as exc: except (URLError, OSError) as exc:
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
with response: 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) length = _content_length(response.headers.get("Content-Length"), label)
if length is not None and length > maximum_bytes: if length is not None and length > maximum_bytes:
raise LauncherUpdateError(f"{label} exceeds its size limit") raise LauncherUpdateError(f"{label} exceeds its size limit")
@@ -363,6 +529,8 @@ def _fetch_small_file(
raise LauncherUpdateError(f"{label} exceeds its size limit") raise LauncherUpdateError(f"{label} exceeds its size limit")
if length is not None and len(data) != length: if length is not None and len(data) != length:
raise LauncherUpdateError(f"{label} response was incomplete") raise LauncherUpdateError(f"{label} response was incomplete")
if not data:
raise LauncherUpdateError(f"{label} response was empty")
return data return data
@@ -399,12 +567,14 @@ def _verify_manifest_signature(
], ],
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=10, timeout=10,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired) as exc: except (OSError, subprocess.TimeoutExpired) as exc:
raise LauncherUpdateError(f"could not verify update signature: {exc}") from 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") 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}" f"staged launcher version is {staged_version}, expected {manifest.version}"
) )
_self_check_release(staging, manifest.version) _self_check_release(staging, manifest.version)
_validate_release_directory(staging)
staging.chmod(0o755) staging.chmod(0o755)
destination = paths.launcher_releases / manifest.version destination = paths.launcher_releases / manifest.version
if destination.exists() or destination.is_symlink(): if destination.exists() or destination.is_symlink():
@@ -639,6 +810,7 @@ def _validate_release_directory(release: Path) -> str:
Path("ptlinux") / PUBLIC_KEY_FILENAME, Path("ptlinux") / PUBLIC_KEY_FILENAME,
Path("ptdiag/__init__.py"), Path("ptdiag/__init__.py"),
Path("ptdiag/assbin.py"), Path("ptdiag/assbin.py"),
Path("ptdiag/logs.py"),
): ):
if not (release / required).is_file(): if not (release / required).is_file():
raise LauncherUpdateError(f"launcher release is missing {required}") raise LauncherUpdateError(f"launcher release is missing {required}")
@@ -654,9 +826,11 @@ def _self_check_release(release: Path, expected_version: str) -> None:
) )
try: try:
result = subprocess.run( 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, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=15, timeout=15,
check=False, check=False,
) )
@@ -768,6 +942,13 @@ def _signature_url(manifest_url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, "")) 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: def _require_https_url(value: str, label: str) -> None:
parts = urlsplit(value) parts = urlsplit(value)
if ( if (
@@ -787,6 +968,18 @@ def _parse_version(value: str, label: str) -> tuple[int, int, int]:
return int(major), int(minor), int(patch) 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: def _required_string(value: dict[str, object], key: str) -> str:
item = value.get(key) item = value.get(key)
if not isinstance(item, str) or not item: if not isinstance(item, str) or not item:

157
ptlinux/server_status.py Normal file
View File

@@ -0,0 +1,157 @@
"""Official realm health used by the native launcher dashboard."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Callable, Mapping, Protocol
from urllib.request import Request, urlopen
from . import __version__
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
SERVER_STATUS_ENDPOINT = (
"https://pristontale.eu/api/pristontale/developer"
"?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0"
)
MAX_SERVER_STATUS_BYTES = 1024 * 1024
class ServerStatusError(RuntimeError):
"""Raised when the official status response cannot be interpreted safely."""
class ServerStatusResponse(Protocol):
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> ServerStatusResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
ServerStatusOpener = Callable[..., ServerStatusResponse]
@dataclass(frozen=True)
class RealmServerStatus:
name: str
online: bool | None
players_online: int | None
login_online: bool | None = None
game_online: bool | None = None
def fetch_server_status(
*,
timeout_seconds: float = 10.0,
opener: ServerStatusOpener = urlopen,
) -> tuple[RealmServerStatus, ...]:
request = Request(
SERVER_STATUS_ENDPOINT,
headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_SERVER_STATUS_BYTES + 1)
except OSError as exc:
raise ServerStatusError("official server status is unavailable") from exc
if len(payload) > MAX_SERVER_STATUS_BYTES:
raise ServerStatusError("official server status exceeds the 1 MiB limit")
return parse_server_status(payload)
def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]:
try:
value = json.loads(payload.decode("utf-8-sig"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ServerStatusError("official server status is not valid JSON") from exc
# Historical versions returned an array containing the status object. The
# current endpoint returns that object directly.
if isinstance(value, list) and value:
value = value[0]
if not isinstance(value, dict):
raise ServerStatusError("official server status is not a realm object")
statuses: list[RealmServerStatus] = []
for raw_name, raw_components in value.items():
if not isinstance(raw_name, str) or raw_name.casefold() == "website":
continue
name = raw_name.strip()
if not name or len(name) > 64 or not isinstance(raw_components, Mapping):
continue
status = _parse_realm(name, raw_components)
if status is not None:
statuses.append(status)
if not statuses:
raise ServerStatusError("official server status contains no realms")
return tuple(statuses)
def _parse_realm(
name: str,
components: Mapping[object, object],
) -> RealmServerStatus | None:
service_states: list[bool] = []
login_online: bool | None = None
game_online: bool | None = None
players_online: int | None = None
for raw_key, raw_value in components.items():
if not isinstance(raw_key, str):
continue
key = raw_key.casefold()
if key == "users.online":
players_online = _non_negative_integer(raw_value)
continue
if not key.startswith("server."):
continue
state = _online_state(raw_value)
if state is None:
continue
service_states.append(state)
if "login" in key:
login_online = state
elif "game" in key:
game_online = state
if not service_states and players_online is None:
return None
online = all(service_states) if service_states else None
display_name = name[0].upper() + name[1:]
return RealmServerStatus(
name=display_name,
online=online,
players_online=players_online,
login_online=login_online,
game_online=game_online,
)
def _online_state(value: object) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value > 0
if isinstance(value, str):
text = value.strip().casefold()
if text in {"online", "up", "true", "1"}:
return True
if text in {"offline", "down", "false", "0"}:
return False
return None
def _non_negative_integer(value: object) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int) and value >= 0:
return value
if isinstance(value, str) and value.isascii() and value.isdecimal():
return int(value)
return None

View File

@@ -2,16 +2,24 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass, fields, replace
import json import json
import os import os
from pathlib import Path from pathlib import Path
import secrets
import threading
from typing import Any
from .locking import launcher_operation_lock
from .paths import AppPaths from .paths import AppPaths
SETTINGS_SCHEMA = 1
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
realm: str = "Babel"
width: int = 1600 width: int = 1600
height: int = 900 height: int = 900
output_width: int = 1600 output_width: int = 1600
@@ -19,9 +27,11 @@ class Settings:
refresh_rate: int = 60 refresh_rate: int = 60
fullscreen: bool = False fullscreen: bool = False
borderless: bool = True borderless: bool = True
suppress_unresponsive_warnings: bool = True
scaler: str = "fit" scaler: str = "fit"
filter: str = "linear" filter: str = "linear"
runtime: str = "auto" runtime: str = "auto"
proton_sync: str = "auto"
proton_path: str | None = None proton_path: str | None = None
game_source: str | None = None game_source: str | None = None
@@ -31,15 +41,100 @@ class Settings:
def load_settings(paths: AppPaths) -> Settings: def load_settings(paths: AppPaths) -> Settings:
try: value = _read_settings_object(paths.settings_file)
value = json.loads(paths.settings_file.read_text(encoding="utf-8")) if value is None:
except FileNotFoundError:
return Settings() return Settings()
except (OSError, json.JSONDecodeError) as exc: return _settings_from_object(value)
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
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): 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( settings = Settings(
realm=_string(value, "realm", "Babel").strip(),
width=_integer(value, "width", 1600), width=_integer(value, "width", 1600),
height=_integer(value, "height", 900), height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600), output_width=_integer(value, "output_width", 1600),
@@ -47,9 +142,15 @@ def load_settings(paths: AppPaths) -> Settings:
refresh_rate=_integer(value, "refresh_rate", 60), refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False), fullscreen=_boolean(value, "fullscreen", False),
borderless=_boolean(value, "borderless", True), borderless=_boolean(value, "borderless", True),
suppress_unresponsive_warnings=_boolean(
value,
"suppress_unresponsive_warnings",
True,
),
scaler=_string(value, "scaler", "fit"), scaler=_string(value, "scaler", "fit"),
filter=_string(value, "filter", "linear"), filter=_string(value, "filter", "linear"),
runtime=_string(value, "runtime", "auto"), runtime=_string(value, "runtime", "auto"),
proton_sync=_string(value, "proton_sync", "auto"),
proton_path=_optional_string(value, "proton_path"), proton_path=_optional_string(value, "proton_path"),
game_source=_optional_string(value, "game_source"), game_source=_optional_string(value, "game_source"),
) )
@@ -57,28 +158,71 @@ def load_settings(paths: AppPaths) -> Settings:
return settings return settings
def save_settings(paths: AppPaths, settings: Settings) -> None: def _decode_settings(content: bytes) -> str:
_validate(settings) """Accept native UTF-8 plus settings saved by common Windows editors."""
paths.config.mkdir(parents=True, exist_ok=True)
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( 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( try:
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n", with temporary.open("x", encoding="utf-8", newline="\n") as handle:
encoding="utf-8", json.dump(payload, handle, indent=2, sort_keys=True)
) handle.write("\n")
temporary.replace(paths.settings_file) 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: def _fsync_directory(path: Path) -> None:
if paths.settings_file.exists(): try:
return load_settings(paths) descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
settings = Settings() except OSError:
save_settings(paths, settings) return
return settings try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def _validate(settings: Settings) -> None: def _validate(settings: Settings) -> None:
if (
len(settings.realm) > 64
or not settings.realm.strip()
or any(ord(character) < 32 for character in settings.realm)
):
raise ValueError("realm must be a non-empty display name")
for name, value in ( for name, value in (
("width", settings.width), ("width", settings.width),
("height", settings.height), ("height", settings.height),
@@ -90,11 +234,13 @@ def _validate(settings: Settings) -> None:
if settings.refresh_rate < 24 or settings.refresh_rate > 1000: if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
raise ValueError("refresh_rate must be between 24 and 1000") raise ValueError("refresh_rate must be between 24 and 1000")
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}: 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"}: if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
raise ValueError(f"unsupported Gamescope filter: {settings.filter}") raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
if settings.runtime not in {"auto", "proton7", "umu"}: if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
raise ValueError(f"unsupported runtime selection: {settings.runtime}") 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: def _integer(value: dict[str, object], key: str, default: int) -> int:
@@ -125,3 +271,6 @@ def _optional_string(value: dict[str, object], key: str) -> str | None:
if not isinstance(item, str) or not item: if not isinstance(item, str) or not item:
raise ValueError(f"{key} must be null or a non-empty string") raise ValueError(f"{key} must be null or a non-empty string")
return item return item
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))

705
ptlinux/tk_chrome.py Normal file
View File

@@ -0,0 +1,705 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Literal
from .desktop_identity import APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE
DialogKind = Literal["info", "success", "warning", "error", "question"]
@dataclass(frozen=True)
class DialogPalette:
"""Colors and glyph used by one launcher dialog intent."""
accent: str
accent_soft: str
glyph: str
_DIALOG_PALETTES: dict[str, DialogPalette] = {
"info": DialogPalette("#d98927", "#3a2819", "i"),
"success": DialogPalette("#55b878", "#173122", "✓"),
"warning": DialogPalette("#e0a248", "#3b2a18", "!"),
"error": DialogPalette("#cf604c", "#3a1d19", "×"),
"question": DialogPalette("#d98927", "#3a2819", "?"),
}
def dialog_palette(kind: DialogKind) -> DialogPalette:
"""Return the stable visual palette for a themed launcher dialog."""
try:
return _DIALOG_PALETTES[kind]
except KeyError as error:
raise ValueError(f"unsupported launcher dialog kind: {kind}") from error
def square_icon_layout(width: int, height: int) -> tuple[int, int, int]:
"""Return square canvas size and centered offsets for a window icon."""
if width <= 0 or height <= 0:
raise ValueError("icon dimensions must be positive")
size = max(width, height)
return size, (size - width) // 2, (size - height) // 2
def icon_subsample_factor(width: int, height: int, max_dimension: int) -> int:
"""Return the integer Tk subsample needed to fit an image within a square."""
if width <= 0 or height <= 0:
raise ValueError("icon dimensions must be positive")
if max_dimension <= 0:
raise ValueError("maximum icon dimension must be positive")
largest_dimension = max(width, height)
return max(1, (largest_dimension + max_dimension - 1) // max_dimension)
def load_square_window_icon(master: Any, path: Path) -> Any:
"""Load a PNG into a square transparent Tk image for desktop shells."""
import tkinter as tk
source = tk.PhotoImage(master=master, file=str(path))
size, offset_x, offset_y = square_icon_layout(source.width(), source.height())
if source.width() == source.height():
return source
icon = tk.PhotoImage(master=master, width=size, height=size)
icon.tk.call(
str(icon),
"copy",
str(source),
"-to",
offset_x,
offset_y,
)
return icon
def centered_window_geometry(
width: int,
height: int,
*,
parent_x: int,
parent_y: int,
parent_width: int,
parent_height: int,
) -> str:
"""Return Tk geometry centered over a parent window."""
if width <= 0 or height <= 0:
raise ValueError("window dimensions must be positive")
x = parent_x + (max(1, parent_width) - width) // 2
y = parent_y + (max(1, parent_height) - height) // 2
return f"{width}x{height}{_geometry_offset(x)}{_geometry_offset(y)}"
def dragged_window_geometry(
pointer_x: int,
pointer_y: int,
*,
offset_x: int,
offset_y: int,
) -> str:
"""Return the Tk position for a pointer-driven custom title bar."""
return (
f"{_geometry_offset(pointer_x - offset_x)}"
f"{_geometry_offset(pointer_y - offset_y)}"
)
def _geometry_offset(value: int) -> str:
return f"+{value}" if value >= 0 else str(value)
def _bind_window_drag(handle: Any, *widgets: Any) -> None:
position = [0, 0]
def begin(event: Any) -> None:
position[0] = int(event.x_root) - int(handle.winfo_x())
position[1] = int(event.y_root) - int(handle.winfo_y())
def move(event: Any) -> None:
handle.geometry(
dragged_window_geometry(
int(event.x_root),
int(event.y_root),
offset_x=position[0],
offset_y=position[1],
)
)
for widget in widgets:
widget.bind("<ButtonPress-1>", begin, add="+")
widget.bind("<B1-Motion>", move, add="+")
class DashboardChrome:
"""Controller for one frameless launcher window and its controls."""
def __init__(
self,
root: Any,
content: Any,
close_button: Any,
minimize_button: Any,
*,
managed: bool,
identity_configured: bool,
allow_close: bool,
) -> None:
self.root = root
self.content = content
self.managed = managed
self.identity_configured = identity_configured
self._close_button = close_button
self._minimize_button = minimize_button
self._allow_close = allow_close
self._close_command: Callable[[], None] = root.destroy
if close_button is not None:
close_button.configure(command=self.close)
if minimize_button is not None:
minimize_button.configure(command=self.minimize)
root.bind("<Alt-F4>", self._close_from_event, add="+")
def set_close_command(self, command: Callable[[], None]) -> None:
self._close_command = command
def close(self) -> None:
if self._allow_close:
self._close_command()
def minimize(self) -> None:
"""Ask the window manager to iconify the managed dashboard."""
import tkinter as tk
try:
self.root.iconify()
except tk.TclError:
pass
def _close_from_event(self, _event: Any) -> str:
self.close()
return "break"
def _configure_x11_window(
root: Any,
*,
resource_name: str,
resource_class: str,
) -> tuple[bool, bool]:
"""Hide the X11 frame and give desktop shells a stable window identity."""
import ctypes.util
library_path = ctypes.util.find_library("X11")
if library_path is None:
return False, False
try:
x11 = ctypes.CDLL(library_path)
except OSError:
return False, False
class XClassHint(ctypes.Structure):
_fields_ = [
("res_name", ctypes.c_char_p),
("res_class", ctypes.c_char_p),
]
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
x11.XOpenDisplay.restype = ctypes.c_void_p
x11.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
ctypes.POINTER(ctypes.c_uint),
]
x11.XQueryTree.restype = ctypes.c_int
x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
x11.XInternAtom.restype = ctypes.c_ulong
x11.XChangeProperty.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_int,
ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int,
]
x11.XChangeProperty.restype = ctypes.c_int
x11.XSetClassHint.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(XClassHint),
]
x11.XSetClassHint.restype = ctypes.c_int
x11.XFlush.argtypes = [ctypes.c_void_p]
x11.XFlush.restype = ctypes.c_int
x11.XFree.argtypes = [ctypes.c_void_p]
x11.XFree.restype = ctypes.c_int
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
x11.XCloseDisplay.restype = ctypes.c_int
root.update_idletasks()
display = x11.XOpenDisplay(None)
if display is None:
return False, False
children = ctypes.POINTER(ctypes.c_ulong)()
try:
root_window = ctypes.c_ulong()
wrapper_window = ctypes.c_ulong()
child_count = ctypes.c_uint()
queried = x11.XQueryTree(
display,
root.winfo_id(),
ctypes.byref(root_window),
ctypes.byref(wrapper_window),
ctypes.byref(children),
ctypes.byref(child_count),
)
if not queried or not wrapper_window.value:
return False, False
atom = x11.XInternAtom(display, b"_MOTIF_WM_HINTS", 0)
if not atom:
return False, False
motif_hints_decorations = 1 << 1
hints = (ctypes.c_ulong * 5)(motif_hints_decorations, 0, 0, 0, 0)
changed = x11.XChangeProperty(
display,
wrapper_window.value,
atom,
atom,
32,
0,
ctypes.cast(hints, ctypes.POINTER(ctypes.c_ubyte)),
len(hints),
)
identity = XClassHint(
res_name=resource_name.encode("utf-8"),
res_class=resource_class.encode("utf-8"),
)
identity_changed = x11.XSetClassHint(
display,
wrapper_window.value,
ctypes.byref(identity),
)
x11.XFlush(display)
return bool(changed), bool(identity_changed)
finally:
if children:
x11.XFree(children)
x11.XCloseDisplay(display)
def create_window_chrome(
root: Any,
*,
title: str,
subtitle: str,
window_icon: Any | None = None,
show_title_bar: bool = True,
show_close: bool = True,
show_minimize: bool = False,
allow_close: bool = True,
) -> DashboardChrome:
"""Install polished managed chrome around one launcher-owned Tk window."""
import tkinter as tk
managed, identity_configured = _configure_x11_window(
root,
resource_name=APPLICATION_WM_INSTANCE,
resource_class=APPLICATION_WM_CLASS,
)
if not managed:
root.overrideredirect(True)
border = tk.Frame(
root,
background="#6a482d",
borderwidth=0,
highlightthickness=0,
)
border.pack(fill="both", expand=True)
surface = tk.Frame(
border,
background="#0c0908",
borderwidth=0,
highlightthickness=0,
)
surface.pack(fill="both", expand=True, padx=1, pady=1)
title_bar = tk.Frame(
surface,
background="#17100d",
height=32,
borderwidth=0,
highlightthickness=0,
cursor="fleur",
)
if show_title_bar:
title_bar.pack(fill="x")
title_bar.pack_propagate(False)
tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
tk_accent.pack(side="left", fill="y")
title_icon: Any | None = None
if window_icon is not None:
try:
factor = icon_subsample_factor(
window_icon.width(),
window_icon.height(),
20,
)
title_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError):
title_icon = None
if title_icon is not None:
mark = tk.Label(
title_bar,
image=title_icon,
width=22,
height=22,
background="#17100d",
borderwidth=0,
highlightthickness=0,
cursor="fleur",
)
mark.image = title_icon
else:
mark = tk.Frame(
title_bar,
width=22,
height=22,
background="#17100d",
borderwidth=0,
highlightthickness=0,
cursor="fleur",
)
mark.pack(side="left", padx=(9, 7))
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
identity.pack(side="left", fill="y")
title_label = tk.Label(
identity,
text=title,
background="#17100d",
foreground="#f2dfbd",
font=("Sans", 8, "bold"),
cursor="fleur",
)
title_label.pack(side="left", pady=(0, 1))
subtitle_label = tk.Label(
identity,
text=f" · {subtitle}",
background="#17100d",
foreground="#8f7b69",
font=("Sans", 7),
cursor="fleur",
)
subtitle_label.pack(side="left", pady=(0, 1))
close_button: Any = None
if show_close:
close_button = tk.Button(
title_bar,
text="×",
background="#17100d",
foreground="#c9b7a4",
activebackground="#a63f2f",
activeforeground="#ffffff",
font=("Sans", 12),
width=4,
borderwidth=0,
highlightthickness=0,
relief="flat",
cursor="hand2",
takefocus=False,
)
close_button.pack(side="right", fill="y")
minimize_button: Any = None
if show_minimize:
minimize_button = tk.Button(
title_bar,
text="—",
background="#17100d",
foreground="#c9b7a4",
activebackground="#312219",
activeforeground="#ffffff",
font=("Sans", 10, "bold"),
width=4,
borderwidth=0,
highlightthickness=0,
relief="flat",
cursor="hand2",
takefocus=False,
)
minimize_button.pack(side="right", fill="y")
content = tk.Frame(
surface,
background="#0c0908",
borderwidth=0,
highlightthickness=0,
)
content.pack(fill="both", expand=True)
if show_title_bar:
_bind_window_drag(root, title_bar, mark, identity, title_label, subtitle_label)
return DashboardChrome(
root,
content,
close_button,
minimize_button,
managed=managed,
identity_configured=identity_configured,
allow_close=allow_close,
)
def create_dashboard_chrome(
root: Any,
*,
title: str,
subtitle: str,
window_icon: Any | None = None,
) -> DashboardChrome:
"""Install the draggable frame and full controls used by the dashboard."""
return create_window_chrome(
root,
title=title,
subtitle=subtitle,
window_icon=window_icon,
show_close=True,
show_minimize=True,
allow_close=True,
)
def show_themed_dialog(
parent: Any,
*,
title: str,
message: str,
kind: DialogKind = "info",
confirm: bool = False,
accept_text: str = "OK",
cancel_text: str = "CANCEL",
window_icon: Any | None = None,
) -> bool:
"""Show one frameless modal dialog matching the native launcher palette."""
import tkinter as tk
palette = dialog_palette(kind)
dialog = tk.Toplevel(parent)
dialog.withdraw()
dialog.title(title)
dialog.configure(background=palette.accent)
dialog.resizable(False, False)
dialog.overrideredirect(True)
if window_icon is not None:
try:
dialog.iconphoto(False, window_icon)
except tk.TclError:
pass
try:
dialog.transient(parent)
except tk.TclError:
pass
result = False
def finish(value: bool) -> None:
nonlocal result
result = value
try:
dialog.grab_release()
except tk.TclError:
pass
dialog.destroy()
border = tk.Frame(dialog, background=palette.accent)
border.pack(fill="both", expand=True)
surface = tk.Frame(border, background="#191311")
surface.pack(fill="both", expand=True, padx=1, pady=1)
header = tk.Frame(surface, background="#24170f", height=66)
header.pack(fill="x")
header.pack_propagate(False)
tk_accent = tk.Frame(header, background=palette.accent, width=5)
tk_accent.pack(side="left", fill="y")
header_copy = tk.Frame(header, background="#24170f")
header_copy.pack(side="left", fill="both", expand=True, padx=16, pady=10)
eyebrow = tk.Label(
header_copy,
text="PRISTONTALE EU · LINUX LAUNCHER",
background="#24170f",
foreground=palette.accent,
font=("Sans", 7, "bold"),
anchor="w",
)
eyebrow.pack(fill="x")
title_label = tk.Label(
header_copy,
text=title,
background="#24170f",
foreground="#fff0d2",
font=("Georgia", 15, "bold"),
anchor="w",
)
title_label.pack(fill="x", pady=(1, 0))
close_button = tk.Button(
header,
text="×",
command=lambda: finish(False),
background="#24170f",
foreground="#c9b7a4",
activebackground="#a63f2f",
activeforeground="#ffffff",
font=("Sans", 12),
width=4,
borderwidth=0,
highlightthickness=0,
relief="flat",
cursor="hand2",
takefocus=False,
)
close_button.pack(side="right", fill="y")
body = tk.Frame(surface, background="#191311", padx=20, pady=20)
body.pack(fill="both", expand=True)
modal_icon: Any | None = None
if window_icon is not None:
try:
factor = icon_subsample_factor(
window_icon.width(),
window_icon.height(),
46,
)
modal_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError):
modal_icon = None
if modal_icon is not None:
icon_host = tk.Frame(
body,
width=46,
height=46,
background="#191311",
borderwidth=0,
highlightthickness=0,
)
icon_host.pack(side="left", anchor="n", padx=(0, 16))
icon_host.pack_propagate(False)
icon = tk.Label(
icon_host,
image=modal_icon,
background="#191311",
borderwidth=0,
highlightthickness=0,
)
icon.place(relx=0.5, rely=0.5, anchor="center")
else:
icon = tk.Canvas(
body,
width=46,
height=46,
background="#191311",
borderwidth=0,
highlightthickness=0,
)
icon.create_oval(
3,
3,
43,
43,
fill=palette.accent_soft,
outline=palette.accent,
width=2,
)
icon.create_text(
23,
23,
text=palette.glyph,
fill=palette.accent,
font=("Sans", 17, "bold"),
)
icon.pack(side="left", anchor="n", padx=(0, 16))
message_label = tk.Label(
body,
text=message,
background="#191311",
foreground="#ded1c4",
font=("Sans", 10),
justify="left",
anchor="w",
wraplength=420,
)
message_label.pack(side="left", fill="both", expand=True, anchor="n")
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
footer.pack(fill="x")
def button(
container: Any,
*,
label: str,
command: Callable[[], None],
primary: bool,
) -> Any:
background = palette.accent if primary else "#2a201c"
foreground = "#17100d" if primary else "#ead8c2"
active_background = "#efae57" if primary else "#3a2b25"
return tk.Button(
container,
text=label,
command=command,
background=background,
foreground=foreground,
activebackground=active_background,
activeforeground="#ffffff" if not primary else "#17100d",
font=("Sans", 8, "bold"),
padx=16,
pady=7,
borderwidth=0,
highlightthickness=0,
relief="flat",
cursor="hand2",
)
accept_button = button(
footer,
label=accept_text,
command=lambda: finish(True),
primary=True,
)
accept_button.pack(side="right")
if confirm:
cancel_button = button(
footer,
label=cancel_text,
command=lambda: finish(False),
primary=False,
)
cancel_button.pack(side="right", padx=(0, 8))
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
dialog.bind("<Escape>", lambda _event: finish(False))
dialog.bind("<Return>", lambda _event: finish(True))
parent.update_idletasks()
dialog.update_idletasks()
width = 560
height = max(218, dialog.winfo_reqheight())
dialog.geometry(
centered_window_geometry(
width,
height,
parent_x=parent.winfo_rootx(),
parent_y=parent.winfo_rooty(),
parent_width=parent.winfo_width(),
parent_height=parent.winfo_height(),
)
)
dialog.deiconify()
dialog.lift()
dialog.grab_set()
accept_button.focus_set()
parent.wait_window(dialog)
return result

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "ptdiag" name = "PTEU-Linux-Launcher"
version = "0.5.2" version = "0.8.1"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []

11
updates/stable.json Normal file
View File

@@ -0,0 +1,11 @@
{
"archive_sha256": "bdf72fdb9db79b2372b16feef5935fff7fd654f81e89b376c59f71dac81cc64f",
"archive_size": 255328,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.1/pristontale-eu-linux-update-v0.8.1.tar.gz",
"channel": "stable",
"minimum_updater_version": "0.5.0",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.1",
"published_at": "2026-09-01T18:09:16Z",
"schema": 1,
"version": "0.8.1"
}

BIN
updates/stable.json.sig Normal file

Binary file not shown.