This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.idea/
dist/
release-keys/
config/local.toml
results/*
!results/.gitkeep

72
RELEASE_README.md Normal file
View File

@@ -0,0 +1,72 @@
# PristonTale EU for Linux
Version 0.5.1 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not
included in this archive.
## Install
Keep the archive and checksum file together, then run:
```text
sha256sum -c pristontale-eu-linux-installer-v0-5-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-5-1.tar.gz
cd pristontale-eu-linux-installer-v0-5-1
./install.sh
```
The default installation directory is `~/Games/PristontaleEU`. To use another
disk or directory:
```text
./install.sh --install-dir "/path/to/PristontaleEU"
```
Existing installations at the previous default location are preserved during
upgrades. The installer adds **PristonTale EU** to the application menu and
installs the `pristontale-eu` command in `~/.local/bin`.
## Use
Open **PristonTale EU** from the application menu or run:
```text
pristontale-eu gui
```
The main launcher button automatically shows **Install**, **Update**, or
**Play** according to the managed game's state.
Version 0.5.0 is the self-update bootstrap. Future signed launcher releases are
checked in the background and appear as an **Update Launcher** action. Launcher
updates are staged separately from game files and retain the previous version
for rollback.
## Commands
```text
pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components
pristontale-eu update Install or update official files
pristontale-eu update --repair Verify and repair every game file
pristontale-eu update --dry-run Preview an update
pristontale-eu import-game "/path/to/game" Import an existing installation
pristontale-eu prepare Build the safe runtime view
pristontale-eu play Launch through Gamescope/Proton
pristontale-eu play --no-box Launch without Gamescope
pristontale-eu settings Show current settings
pristontale-eu settings --init Write default settings
pristontale-eu launcher-update --check Check the signed launcher channel
pristontale-eu launcher-update Install and activate an update
pristontale-eu launcher-update --rollback Restore the previous launcher
pristontale-eu sync Sync without import discovery
pristontale-eu wine-update Run the legacy WPF updater
pristontale-eu fetch Fetch the publisher launcher
pristontale-eu setup Prepare its Wine environment
pristontale-eu cache-compatibility PATH Cache a verified compatibility pair
pristontale-eu cache-runtime-patch PATH Cache a verified runtime patch
```
Use `./install.sh --dry-run` to preview dependency installation or
`./install.sh --skip-deps` to manage distribution packages manually.

308
install.sh Executable file
View File

@@ -0,0 +1,308 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
VERSION="${PTLINUX_VERSION:-0.5.1}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
exit 2
fi
DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
BIN_HOME="${HOME}/.local/bin"
APPLICATION_HOME="${DATA_HOME}/applications"
PIXMAP_HOME="${DATA_HOME}/pixmaps"
ICON_PATH="${PIXMAP_HOME}/eu.pristontale.PTLinux.png"
DEFAULT_INSTALL_DIRECTORY="${HOME}/Games/PristontaleEU"
LEGACY_INSTALL_DIRECTORY="${DATA_HOME}/pristontale-eu"
INSTALL_DIRECTORY_EXPLICIT=0
if [[ -n "${PTLINUX_INSTALL_DIR:-}" ]]
then
INSTALL_DIRECTORY="$PTLINUX_INSTALL_DIR"
INSTALL_DIRECTORY_EXPLICIT=1
else
INSTALL_DIRECTORY="$DEFAULT_INSTALL_DIRECTORY"
fi
PRESERVED_LEGACY_INSTALL=0
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
DRY_RUN=0
RUN_DOCTOR=1
INSTALL_DEPENDENCIES=1
usage()
{
printf '%s\n' \
"Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor]" \
"" \
" --install-dir PATH Store the app, managed game, and runtime data at PATH." \
" Default: ~/Games/PristontaleEU"
}
while (( $# > 0 ))
do
case "$1" in
--install-dir)
if (( $# < 2 ))
then
printf '%s\n' "--install-dir requires a path." >&2
exit 2
fi
INSTALL_DIRECTORY="$2"
INSTALL_DIRECTORY_EXPLICIT=1
shift 2
;;
--install-dir=*)
INSTALL_DIRECTORY="${1#*=}"
INSTALL_DIRECTORY_EXPLICIT=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-doctor)
RUN_DOCTOR=0
shift
;;
--skip-deps)
INSTALL_DEPENDENCIES=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if (( INSTALL_DIRECTORY_EXPLICIT == 0 )) \
&& [[ ! -d "${DEFAULT_INSTALL_DIRECTORY}/app/ptlinux" \
&& ! -d "${DEFAULT_INSTALL_DIRECTORY}/app/current/ptlinux" ]] \
&& [[ -d "${LEGACY_INSTALL_DIRECTORY}/app/ptlinux" \
|| -d "${LEGACY_INSTALL_DIRECTORY}/app/current/ptlinux" \
|| -f "${LEGACY_INSTALL_DIRECTORY}/launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe" ]]
then
INSTALL_DIRECTORY="$LEGACY_INSTALL_DIRECTORY"
PRESERVED_LEGACY_INSTALL=1
fi
if [[ -z "$INSTALL_DIRECTORY" \
|| "$INSTALL_DIRECTORY" == *$'\n'* \
|| "$INSTALL_DIRECTORY" == *$'\r'* ]]
then
printf '%s\n' "Install directory is empty or contains invalid characters." >&2
exit 2
fi
case "$INSTALL_DIRECTORY" in
"~")
INSTALL_DIRECTORY="$HOME"
;;
"~/"*)
INSTALL_DIRECTORY="${HOME}/${INSTALL_DIRECTORY:2}"
;;
"~"*)
printf '%s\n' "Use an absolute path instead of another user's ~ directory." >&2
exit 2
;;
esac
if [[ "$INSTALL_DIRECTORY" != /* ]]
then
INSTALL_DIRECTORY="${PWD}/${INSTALL_DIRECTORY}"
fi
if command -v realpath >/dev/null 2>&1
then
INSTALL_DIRECTORY="$(realpath -m -- "$INSTALL_DIRECTORY")"
fi
if [[ "$INSTALL_DIRECTORY" == "/" ]]
then
printf '%s\n' "Refusing to use the filesystem root as the install directory." >&2
exit 2
fi
APP_ROOT="${INSTALL_DIRECTORY}/app"
RELEASES_ROOT="${APP_ROOT}/releases"
RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|| ! -f "${SOURCE_ROOT}/packaging/install-dependencies.sh" ]]
then
printf '%s\n' "Installer source is incomplete." >&2
exit 1
fi
printf '%s\n' "Installing PristonTale EU Linux launcher for the current user"
if (( PRESERVED_LEGACY_INSTALL == 1 ))
then
printf '%s\n' " Existing installation found; preserving its previous data location."
fi
printf ' Install data: %s\n' "$INSTALL_DIRECTORY"
printf ' Application: %s\n' "$APP_ROOT"
printf ' Command: %s\n' "${BIN_HOME}/pristontale-eu"
printf ' Desktop: %s\n' "${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop"
if (( INSTALL_DEPENDENCIES == 1 ))
then
# shellcheck source=packaging/install-dependencies.sh
source "${SOURCE_ROOT}/packaging/install-dependencies.sh"
ptlinux_install_host_dependencies "$DRY_RUN"
else
printf '%s\n' "Skipping host dependency installation."
fi
if (( DRY_RUN == 1 ))
then
exit 0
fi
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1
then
printf 'Python was not found after dependency installation: %s\n' "$PYTHON_BIN" >&2
exit 1
fi
if ! "$PYTHON_BIN" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
then
printf '%s\n' "Python 3.11 or newer is required." >&2
exit 1
fi
install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME"
STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging"
if [[ -e "$STAGING_RELEASE" || -L "$STAGING_RELEASE" ]]
then
printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2
exit 1
fi
install -d -m 0755 "$STAGING_RELEASE"
install -d -m 0755 "${STAGING_RELEASE}/ptlinux" "${STAGING_RELEASE}/ptdiag"
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/"
install -m 0644 \
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
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"
printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
chmod 0644 "${STAGING_RELEASE}/release.json"
if ! "$PYTHON_BIN" -I -c \
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli, ptlinux.self_update; raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)' \
"$STAGING_RELEASE" "$VERSION"
then
printf '%s\n' "The staged launcher failed its import/version check." >&2
rm -r -- "$STAGING_RELEASE"
exit 1
fi
REPLACED_RELEASE=""
if [[ -e "$RELEASE_ROOT" || -L "$RELEASE_ROOT" ]]
then
if [[ ! -d "$RELEASE_ROOT" || -L "$RELEASE_ROOT" ]]
then
printf 'Existing launcher release is not a directory: %s\n' "$RELEASE_ROOT" >&2
rm -r -- "$STAGING_RELEASE"
exit 1
fi
REPLACED_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.replaced"
mv -- "$RELEASE_ROOT" "$REPLACED_RELEASE"
fi
if ! mv -- "$STAGING_RELEASE" "$RELEASE_ROOT"
then
if [[ -n "$REPLACED_RELEASE" && -d "$REPLACED_RELEASE" ]]
then
mv -- "$REPLACED_RELEASE" "$RELEASE_ROOT"
fi
exit 1
fi
if [[ -n "$REPLACED_RELEASE" && -d "$REPLACED_RELEASE" ]]
then
rm -r -- "$REPLACED_RELEASE"
fi
CURRENT_TARGET=""
if [[ -L "${APP_ROOT}/current" ]]
then
CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")"
if [[ ! "$CURRENT_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2
exit 1
fi
if [[ ! -d "${APP_ROOT}/${CURRENT_TARGET}" ]]
then
printf 'Existing launcher current link is broken: %s\n' "$CURRENT_TARGET" >&2
exit 1
fi
elif [[ -e "${APP_ROOT}/current" ]]
then
printf 'Launcher current path is not a symbolic link: %s\n' "${APP_ROOT}/current" >&2
exit 1
fi
if [[ -L "${APP_ROOT}/previous" ]]
then
PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")"
if [[ ! "$PREVIOUS_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ \
|| ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]]
then
printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2
exit 1
fi
elif [[ -e "${APP_ROOT}/previous" ]]
then
printf 'Launcher previous path is not a symbolic link: %s\n' "${APP_ROOT}/previous" >&2
exit 1
fi
if [[ -n "$CURRENT_TARGET" && "$CURRENT_TARGET" != "releases/${VERSION}" ]]
then
PREVIOUS_LINK="${APP_ROOT}/.previous.$$.tmp"
ln -s -- "$CURRENT_TARGET" "$PREVIOUS_LINK"
mv -Tf -- "$PREVIOUS_LINK" "${APP_ROOT}/previous"
fi
CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/launcher_wrapper.py" \
"${SOURCE_ROOT}/packaging/pristontale-eu" \
"$APP_ROOT" \
"$INSTALL_DIRECTORY" \
"${BIN_HOME}/pristontale-eu"
install -m 0644 \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"$ICON_PATH"
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/desktop_entry.py" \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${BIN_HOME}/pristontale-eu" \
"$ICON_PATH" \
"${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop"
if command -v desktop-file-validate >/dev/null 2>&1
then
desktop-file-validate "${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop"
fi
if command -v update-desktop-database >/dev/null 2>&1
then
update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true
fi
printf '%s\n' "Application installed."
if (( RUN_DOCTOR == 1 ))
then
"${BIN_HOME}/pristontale-eu" doctor || true
fi

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=PristonTale EU
Comment=Install, update, and play PristonTale EU in a clean Linux game window
Exec=@PTLINUX_EXECUTABLE@ gui
Icon=@PTLINUX_ICON@
Terminal=false
Categories=Game;RolePlaying;
Keywords=PristonTale;MMORPG;Wine;Proton;
StartupNotify=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

411
packaging/install-dependencies.sh Executable file
View File

@@ -0,0 +1,411 @@
#!/usr/bin/env bash
# Host dependency bootstrap shared by install.sh and release validation.
ptlinux_dependency_family()
{
local distribution_id="${1,,}"
local distribution_like=" ${2,,} "
case "$distribution_id" in
ubuntu|debian|linuxmint|pop|elementary|zorin)
printf '%s\n' "debian"
return 0
;;
fedora|nobara)
printf '%s\n' "fedora"
return 0
;;
arch|cachyos|endeavouros|garuda|manjaro)
printf '%s\n' "arch"
return 0
;;
opensuse-tumbleweed|opensuse-leap|opensuse|suse)
printf '%s\n' "suse"
return 0
;;
esac
case "$distribution_like" in
*" debian "*|*" ubuntu "*)
printf '%s\n' "debian"
;;
*" fedora "*)
printf '%s\n' "fedora"
;;
*" arch "*)
printf '%s\n' "arch"
;;
*" suse "*)
printf '%s\n' "suse"
;;
*)
printf '%s\n' "unknown"
;;
esac
}
ptlinux_resolve_dependency_packages()
{
local family="$1"
shift
local dependency
local package
local -a candidates=()
local -a packages=()
for dependency in "$@"
do
candidates=()
case "${family}:${dependency}" in
debian:python)
candidates=(python3)
;;
debian:python-tk)
candidates=(python3-tk)
;;
debian:wine)
candidates=(wine wine64 wine32:i386)
;;
debian:winetricks)
candidates=(winetricks)
;;
debian:gamescope)
candidates=(gamescope)
;;
debian:cabextract)
candidates=(cabextract)
;;
debian:openssl)
candidates=(openssl)
;;
fedora:python)
candidates=(python3)
;;
fedora:python-tk)
candidates=(python3-tkinter)
;;
fedora:wine)
candidates=(wine)
;;
fedora:winetricks)
candidates=(winetricks)
;;
fedora:gamescope)
candidates=(gamescope)
;;
fedora:cabextract)
candidates=(cabextract)
;;
fedora:openssl)
candidates=(openssl)
;;
arch:python)
candidates=(python)
;;
arch:python-tk)
candidates=(tk)
;;
arch:wine)
candidates=(wine)
;;
arch:winetricks)
candidates=(winetricks)
;;
arch:gamescope)
candidates=(gamescope)
;;
arch:cabextract)
candidates=(cabextract)
;;
arch:openssl)
candidates=(openssl)
;;
suse:python)
candidates=(python3)
;;
suse:python-tk)
candidates=(python3-tk)
;;
suse:wine)
candidates=(wine)
;;
suse:winetricks)
candidates=(winetricks)
;;
suse:gamescope)
candidates=(gamescope)
;;
suse:cabextract)
candidates=(cabextract)
;;
suse:openssl)
candidates=(openssl)
;;
*)
printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2
return 1
;;
esac
for package in "${candidates[@]}"
do
if [[ " ${packages[*]} " != *" ${package} "* ]]
then
packages+=("$package")
fi
done
done
printf '%s\n' "${packages[*]}"
}
ptlinux_read_distribution()
{
local release_file="${PTLINUX_OS_RELEASE:-/etc/os-release}"
local key
local value
PTLINUX_DISTRIBUTION_ID="unknown"
PTLINUX_DISTRIBUTION_LIKE=""
if [[ ! -r "$release_file" ]]
then
return 0
fi
while IFS='=' read -r key value
do
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
case "$key" in
ID)
PTLINUX_DISTRIBUTION_ID="$value"
;;
ID_LIKE)
PTLINUX_DISTRIBUTION_LIKE="$value"
;;
esac
done < "$release_file"
}
ptlinux_has_wine_stack()
{
local wine_root
if command -v wine >/dev/null 2>&1 && command -v wineboot >/dev/null 2>&1
then
return 0
fi
for wine_root in /opt/wine-stable/bin /opt/wine-staging/bin /opt/wine-devel/bin
do
if [[ -x "${wine_root}/wine" && -x "${wine_root}/wineboot" ]]
then
return 0
fi
done
return 1
}
ptlinux_detect_missing_dependencies()
{
local python_bin="${PYTHON_BIN:-python3}"
PTLINUX_MISSING_DEPENDENCIES=()
if ! command -v "$python_bin" >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(python python-tk)
elif ! "$python_bin" -c 'import _tkinter' >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(python-tk)
fi
if ! ptlinux_has_wine_stack
then
PTLINUX_MISSING_DEPENDENCIES+=(wine)
fi
if ! command -v winetricks >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(winetricks)
fi
if ! command -v gamescope >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(gamescope)
fi
if ! command -v cabextract >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(cabextract)
fi
if ! command -v openssl >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(openssl)
fi
}
ptlinux_select_privilege_command()
{
PTLINUX_PRIVILEGE_COMMAND=()
if (( EUID == 0 ))
then
return 0
fi
if command -v sudo >/dev/null 2>&1
then
PTLINUX_PRIVILEGE_COMMAND=(sudo)
return 0
fi
if command -v doas >/dev/null 2>&1
then
PTLINUX_PRIVILEGE_COMMAND=(doas)
return 0
fi
if command -v pkexec >/dev/null 2>&1
then
PTLINUX_PRIVILEGE_COMMAND=(pkexec)
return 0
fi
printf '%s\n' "Root access is required, but sudo, doas, and pkexec are unavailable." >&2
return 1
}
ptlinux_print_command()
{
printf '%s' " +"
printf ' %q' "$@"
printf '\n'
}
ptlinux_run_as_root()
{
ptlinux_print_command "${PTLINUX_PRIVILEGE_COMMAND[@]}" "$@"
"${PTLINUX_PRIVILEGE_COMMAND[@]}" "$@"
}
ptlinux_install_resolved_packages()
{
local family="$1"
shift
local -a packages=("$@")
local architecture
local has_i386=0
local package
ptlinux_select_privilege_command
case "$family" in
debian)
for package in "${packages[@]}"
do
if [[ "$package" == "wine32:i386" ]]
then
has_i386=1
fi
done
if (( has_i386 == 1 ))
then
while IFS= read -r architecture
do
if [[ "$architecture" == "i386" ]]
then
has_i386=2
fi
done < <(dpkg --print-foreign-architectures)
if (( has_i386 == 1 ))
then
ptlinux_run_as_root dpkg --add-architecture i386
fi
fi
ptlinux_run_as_root apt-get update
ptlinux_run_as_root apt-get install -y "${packages[@]}"
;;
fedora)
if command -v dnf5 >/dev/null 2>&1
then
ptlinux_run_as_root dnf5 install -y "${packages[@]}"
else
ptlinux_run_as_root dnf install -y "${packages[@]}"
fi
;;
arch)
ptlinux_run_as_root pacman -S --needed --noconfirm "${packages[@]}"
;;
suse)
ptlinux_run_as_root zypper --non-interactive install "${packages[@]}"
;;
*)
printf 'Unsupported dependency family: %s\n' "$family" >&2
return 1
;;
esac
}
ptlinux_install_host_dependencies()
{
local dry_run="$1"
local family
local package_line
local -a packages=()
ptlinux_detect_missing_dependencies
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
then
printf '%s\n' "All required host dependencies are already available."
return 0
fi
ptlinux_read_distribution
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
if [[ "$family" == "unknown" ]]
then
printf 'Unsupported distribution: %s. Re-run with --skip-deps after installing: %s\n' \
"$PTLINUX_DISTRIBUTION_ID" "${PTLINUX_MISSING_DEPENDENCIES[*]}" >&2
return 1
fi
package_line="$(ptlinux_resolve_dependency_packages \
"$family" "${PTLINUX_MISSING_DEPENDENCIES[@]}")"
read -r -a packages <<< "$package_line"
printf 'Missing host components: %s\n' "${PTLINUX_MISSING_DEPENDENCIES[*]}"
printf 'Distribution packages: %s\n' "${packages[*]}"
if (( dry_run == 1 ))
then
printf '%s\n' "Dry run: system packages were not changed."
return 0
fi
printf '%s\n' "Installing missing host dependencies (authentication may be requested)."
ptlinux_install_resolved_packages "$family" "${packages[@]}"
hash -r
ptlinux_detect_missing_dependencies
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} != 0 ))
then
printf 'Dependencies remain unavailable after package installation: %s\n' \
"${PTLINUX_MISSING_DEPENDENCIES[*]}" >&2
return 1
fi
printf '%s\n' "Host dependency installation completed."
}
ptlinux_dependency_main()
{
if [[ "${1:-}" != "--resolve" || $# -lt 3 ]]
then
printf '%s\n' "Usage: install-dependencies.sh --resolve DISTRO DEPENDENCY..." >&2
return 2
fi
shift
local distribution_id="$1"
shift
local family
family="$(ptlinux_dependency_family "$distribution_id" "")"
if [[ "$family" == "unknown" ]]
then
printf 'Unsupported distribution: %s\n' "$distribution_id" >&2
return 1
fi
ptlinux_resolve_dependency_packages "$family" "$@"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]
then
set -euo pipefail
ptlinux_dependency_main "$@"
fi

41
packaging/pristontale-eu Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env sh
set -eu
app_home=@PTLINUX_APP_ROOT@
install_directory=@PTLINUX_INSTALL_DIR@
python_bin="${PTLINUX_PYTHON:-python3}"
export PTLINUX_INSTALL_DIR="${install_directory}"
export PTLINUX_APP_HOME="${app_home}"
release_root=""
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
do
if [ ! -d "${candidate}/ptlinux" ]
then
continue
fi
if "$python_bin" -I -c \
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
"${candidate}" >/dev/null 2>&1
then
release_root="${candidate}"
break
fi
done
if [ -z "${release_root}" ]
then
printf '%s\n' "PristonTale EU Linux application files are missing or invalid: ${app_home}" >&2
exit 1
fi
if [ -n "${PYTHONPATH:-}" ]
then
export PYTHONPATH="${release_root}:${PYTHONPATH}"
else
export PYTHONPATH="${release_root}"
fi
exec "$python_bin" -m ptlinux "$@"

28
ptdiag/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""PristonTale EU Linux diagnostic harness and shared binary helpers."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .config import AppConfig, ConfigError, load_config
__all__ = ["AppConfig", "ConfigError", "load_config"]
__version__ = "0.1.0"
def __getattr__(name: str) -> Any:
"""Load diagnostic configuration helpers only when the full harness needs them.
The production launcher ships only :mod:`ptdiag.assbin`, which it uses for
verified scene handling. Deferring the larger diagnostic-only imports keeps
the compact package importable without shipping the investigation harness.
"""
if name in {"AppConfig", "ConfigError", "load_config"}:
from .config import AppConfig, ConfigError, load_config
return {
"AppConfig": AppConfig,
"ConfigError": ConfigError,
"load_config": load_config,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

6
ptdiag/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
from .cli import main
if __name__ == "__main__":
main()

1081
ptdiag/assbin.py Normal file

File diff suppressed because it is too large Load Diff

203
ptdiag/cli.py Normal file
View File

@@ -0,0 +1,203 @@
"""Command-line interface for diagnostics and controlled runtime experiments."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Sequence
from .assbin import (
AssbinError,
extract_embedded_texture,
inspect_wrapped_assbin,
normalize_wrapped_assbin_textures,
)
from .config import ConfigError, load_config
from .experiment import run_experiment
from .filesystem import verify_known_game_hashes
from .ini import IniError, override_ini_value
from .logs import parse_native_log
from .process import collect_environment
from .testcase import TestcaseError, load_testcase
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ptdiag")
parser.add_argument("--config", type=Path, help="path to local.toml")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("env", help="inspect host and configured paths")
subcommands.add_parser("verify", help="verify environment and known core hashes")
log_parser = subcommands.add_parser("log", help="native game-log operations")
log_commands = log_parser.add_subparsers(dest="log_command", required=True)
analyze = log_commands.add_parser("analyze", help="parse a native game log")
analyze.add_argument("path", type=Path)
scene_parser = subcommands.add_parser("scene", help="wrapped scene-cache operations")
scene_commands = scene_parser.add_subparsers(dest="scene_command", required=True)
inspect = scene_commands.add_parser(
"inspect",
help="inspect an XOR-wrapped Assimp .rovug cache",
)
inspect.add_argument("path", type=Path)
inspect.add_argument("--output", type=Path, help="also write the complete JSON result")
extract = scene_commands.add_parser(
"extract-texture",
help="extract and verify one embedded texture from a .rovug cache",
)
extract.add_argument("path", type=Path)
extract.add_argument("index", type=int)
extract.add_argument("output", type=Path)
normalize = scene_commands.add_parser(
"normalize-textures",
help="write a cache with selected 16-bit RGB PNGs normalized to 8-bit",
)
normalize.add_argument("path", type=Path)
normalize.add_argument("output", type=Path)
normalize.add_argument("indices", type=int, nargs="+")
ini_parser = subcommands.add_parser("ini", help="verified INI operations")
ini_commands = ini_parser.add_subparsers(dest="ini_command", required=True)
ini_override = ini_commands.add_parser(
"override",
help="replace one exact INI value after checking its expected value",
)
ini_override.add_argument("path", type=Path)
ini_override.add_argument("section")
ini_override.add_argument("key")
ini_override.add_argument("expected")
ini_override.add_argument("value")
run_parser = subcommands.add_parser("run", help="run a controlled testcase")
run_parser.add_argument("testcase", help="testcase name or TOML path")
run_parser.add_argument(
"--dry-run",
action="store_true",
help="write and validate the complete plan without launching Game.exe",
)
run_parser.add_argument(
"--results-root",
type=Path,
help="override the result-directory root",
)
return parser
def main(argv: Sequence[str] | None = None) -> None:
raise SystemExit(run(argv))
def run(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "log" and args.log_command == "analyze":
try:
analysis = parse_native_log(args.path)
except OSError as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **analysis.to_summary()})
return 0
if args.command == "scene" and args.scene_command == "inspect":
try:
inspection = inspect_wrapped_assbin(args.path)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
payload = {"ok": True, **inspection.to_dict()}
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2, sort_keys=True)
stream.write("\n")
_print_json(payload)
return 0
if args.command == "scene" and args.scene_command == "extract-texture":
try:
inspection = inspect_wrapped_assbin(args.path)
extraction = extract_embedded_texture(
args.path,
inspection,
args.index,
args.output,
)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **extraction})
return 0
if args.command == "scene" and args.scene_command == "normalize-textures":
try:
normalization = normalize_wrapped_assbin_textures(
args.path,
args.output,
tuple(args.indices),
)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **normalization})
return 0
if args.command == "ini" and args.ini_command == "override":
try:
result = override_ini_value(
args.path,
args.section,
args.key,
args.expected,
args.value,
)
except (IniError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json(result)
return 0
try:
config = load_config(args.config)
except ConfigError as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
if args.command == "run":
try:
testcase = load_testcase(args.testcase)
outcome = run_experiment(
config,
testcase,
dry_run=args.dry_run,
results_root=args.results_root,
)
except (TestcaseError, OSError, ValueError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json(
{
"ok": outcome.summary.get("ok", False),
"status": outcome.summary.get("status"),
"result_directory": str(outcome.result_directory),
}
)
return outcome.exit_code
environment = collect_environment(config)
if args.command == "env":
_print_json(environment)
return 0 if environment["ok"] else 1
hashes = verify_known_game_hashes(config.paths.live_game)
hashes_ok = all(item.ok for item in hashes)
payload = {
"ok": bool(environment["ok"]) and hashes_ok,
"environment": environment,
"hashes": [item.to_dict() for item in hashes],
}
_print_json(payload)
return 0 if payload["ok"] else 1
def _print_json(value: object) -> None:
json.dump(value, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")

162
ptdiag/config.py Normal file
View File

@@ -0,0 +1,162 @@
"""Typed TOML configuration loading."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping
import re
import tomllib
DEFAULT_CONFIG_PATH = Path("config/local.toml")
class ConfigError(ValueError):
"""Raised when local configuration is missing or invalid."""
@dataclass(frozen=True)
class PathsConfig:
app_root: Path
live_prefix: Path
live_game: Path
logs: Path
native_logs: Path
@dataclass(frozen=True)
class Proton7Config:
proton: Path
steam_root: Path
compat: Path
prefix: Path
@dataclass(frozen=True)
class GameConfig:
exe: str
args: tuple[str, ...]
virtual_desktop: str
@dataclass(frozen=True)
class SafetyConfig:
allow_live_game_modification: bool
allow_prefix_modification: bool
allow_binary_patch: bool
@dataclass(frozen=True)
class AppConfig:
paths: PathsConfig
proton7: Proton7Config
game: GameConfig
safety: SafetyConfig
source: Path
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
return _paths_to_strings(result)
def project_root() -> Path:
return Path(__file__).resolve().parent.parent
def load_config(path: str | Path | None = None) -> AppConfig:
config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH
config_path = config_path.expanduser().absolute()
try:
with config_path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise ConfigError(
f"Configuration not found: {config_path}. "
"Copy config/local.example.toml to config/local.toml."
) from exc
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
paths = _section(data, "paths")
proton7 = _section(data, "proton7")
game = _section(data, "game")
safety = _section(data, "safety")
virtual_desktop = _required_string(game, "virtual_desktop")
if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None:
raise ConfigError("game.virtual_desktop must look like 1600x900")
return AppConfig(
paths=PathsConfig(
app_root=_required_path(paths, "app_root"),
live_prefix=_required_path(paths, "live_prefix"),
live_game=_required_path(paths, "live_game"),
logs=_required_path(paths, "logs"),
native_logs=_required_path(paths, "native_logs"),
),
proton7=Proton7Config(
proton=_required_path(proton7, "proton"),
steam_root=_required_path(proton7, "steam_root"),
compat=_required_path(proton7, "compat"),
prefix=_required_path(proton7, "prefix"),
),
game=GameConfig(
exe=_required_string(game, "exe"),
args=_required_string_tuple(game, "args"),
virtual_desktop=virtual_desktop,
),
safety=SafetyConfig(
allow_live_game_modification=_required_bool(
safety, "allow_live_game_modification"
),
allow_prefix_modification=_required_bool(
safety, "allow_prefix_modification"
),
allow_binary_patch=_required_bool(safety, "allow_binary_patch"),
),
source=config_path,
)
def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
value = data.get(name)
if not isinstance(value, Mapping):
raise ConfigError(f"Missing or invalid [{name}] section")
return value
def _required_string(section: Mapping[str, Any], key: str) -> str:
value = section.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{key} must be a non-empty string")
return value
def _required_path(section: Mapping[str, Any], key: str) -> Path:
return Path(_required_string(section, key)).expanduser().absolute()
def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, ...]:
value = section.get(key)
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise ConfigError(f"{key} must be a non-empty array of strings")
return tuple(value)
def _required_bool(section: Mapping[str, Any], key: str) -> bool:
value = section.get(key)
if not isinstance(value, bool):
raise ConfigError(f"{key} must be true or false")
return value
def _paths_to_strings(value: Any) -> Any:
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {key: _paths_to_strings(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_paths_to_strings(item) for item in value]
return value

1812
ptdiag/experiment.py Normal file

File diff suppressed because it is too large Load Diff

59
ptdiag/filesystem.py Normal file
View File

@@ -0,0 +1,59 @@
"""Read-only filesystem and hash verification helpers."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from hashlib import sha256
from pathlib import Path
from typing import Mapping
KNOWN_GAME_HASHES: dict[str, str] = {
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
"game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09",
"gamese.exe": "c3443e50a93c6c778eff456122c351e5c7d2c1ceaea50e5a650bffe846a05c87",
"gamese.dll": "0ae6822f5f347f04bd2b0dc947f0286089c711c84703e110f6f8fc6e0d626131",
}
@dataclass(frozen=True)
class HashVerification:
path: Path
expected_sha256: str
actual_sha256: str | None
ok: bool
error: str | None = None
def to_dict(self) -> dict[str, str | bool | None]:
data = asdict(self)
data["path"] = str(self.path)
return data
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
digest = sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(chunk_size), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_file_hash(path: Path, expected_sha256: str) -> HashVerification:
expected = expected_sha256.casefold()
try:
actual = sha256_file(path)
except (OSError, ValueError) as exc:
return HashVerification(path, expected, None, False, str(exc))
return HashVerification(path, expected, actual, actual == expected)
def verify_hashes(expected: Mapping[Path, str]) -> list[HashVerification]:
return [verify_file_hash(path, digest) for path, digest in expected.items()]
def verify_known_game_hashes(live_game: Path) -> list[HashVerification]:
expected = {live_game / name: digest for name, digest in KNOWN_GAME_HASHES.items()}
return verify_hashes(expected)

112
ptdiag/ini.py Normal file
View File

@@ -0,0 +1,112 @@
"""Exact, verified INI value inspection and replacement."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
from typing import Any
from .filesystem import sha256_file
class IniError(ValueError):
"""Raised when an INI override cannot be applied exactly."""
def inspect_ini_value(path: Path, section: str, key: str) -> dict[str, Any]:
if path.is_symlink() or not path.is_file():
raise IniError(f"INI path is missing or unsafe: {path}")
text = _read_ini_text(path)
_, value = _find_ini_value(text, section, key)
state = path.stat()
return {
"path": str(path),
"section": section,
"key": key,
"value": value,
"sha256": sha256_file(path),
"size": state.st_size,
"mtime_ns": state.st_mtime_ns,
}
def override_ini_value(
path: Path,
section: str,
key: str,
expected: str,
value: str,
) -> dict[str, Any]:
before = inspect_ini_value(path, section, key)
if before["value"] != expected:
raise IniError(
f"INI value mismatch for [{section}] {key}: "
f"expected {expected!r}, found {before['value']!r}"
)
text = _read_ini_text(path)
line_index, _ = _find_ini_value(text, section, key)
lines = text.splitlines(keepends=True)
body = lines[line_index].rstrip("\r\n")
newline = lines[line_index][len(body) :]
left, separator, right = body.partition("=")
if not separator:
raise IniError(f"INI key has no equals separator: [{section}] {key}")
leading = right[: len(right) - len(right.lstrip())]
trailing = right[len(right.rstrip()) :]
lines[line_index] = f"{left}={leading}{value}{trailing}{newline}"
updated = "".join(lines)
if updated == text:
raise IniError(f"INI override made no change: [{section}] {key}")
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
if temporary.exists() or temporary.is_symlink():
raise IniError(f"INI temporary path already exists: {temporary}")
try:
with temporary.open("w", encoding="utf-8", newline="") as stream:
stream.write(updated)
stream.flush()
os.fsync(stream.fileno())
shutil.copystat(path, temporary, follow_symlinks=False)
temporary.replace(path)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
after = inspect_ini_value(path, section, key)
if after["value"] != value:
raise IniError(f"INI override verification failed: [{section}] {key}")
if after["sha256"] == before["sha256"]:
raise IniError(f"INI hash did not change: [{section}] {key}")
return {
"ok": True,
"before": before,
"after": after,
"expected": expected,
"value": value,
}
def _read_ini_text(path: Path) -> str:
with path.open("r", encoding="utf-8", newline="") as stream:
return stream.read()
def _find_ini_value(text: str, section: str, key: str) -> tuple[int, str]:
current_section: str | None = None
matches: list[tuple[int, str]] = []
for index, line in enumerate(text.splitlines(keepends=True)):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
current_section = stripped[1:-1].strip()
continue
if current_section is None or current_section.casefold() != section.casefold():
continue
if not stripped or stripped.startswith((";", "#")):
continue
left, separator, right = line.rstrip("\r\n").partition("=")
if separator and left.strip().casefold() == key.casefold():
matches.append((index, right.strip()))
if not matches:
raise IniError(f"INI key not found: [{section}] {key}")
if len(matches) != 1:
raise IniError(f"INI key is duplicated: [{section}] {key}")
return matches[0]

288
ptdiag/logs.py Normal file
View File

@@ -0,0 +1,288 @@
"""Native game-log discovery, freshness validation, and parsing."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
import re
from typing import Iterable, Mapping
DEFAULT_NATIVE_LOG_PATTERNS = ("log_*.txt", "*.log")
_SCENE_RE = re.compile(
r"\[S1\]\s+after import\s+(?P<scene>[^:]+):\s*"
r"materials=(?P<materials>\d+)\s+textures=(?P<textures>\d+)",
re.IGNORECASE,
)
_FAILED_FILE_RE = re.compile(
r"Failed to (?P<operation>find|load|read) file\s*:\s*(?P<path>.*)$",
re.IGNORECASE,
)
_FAILED_IMPORT_RE = re.compile(
r"Failed to import scene:\s*(?P<scene>.*)$",
re.IGNORECASE,
)
_TIMESTAMP_RE = re.compile(
r"^\s*(?:\[)?(?P<timestamp>"
r"\d{4}-\d{2}-\d{2}[T ][0-9:.+Z-]+|\d{2}:\d{2}:\d{2}(?:\.\d+)?)"
r"(?:\])?"
)
@dataclass(frozen=True)
class LogFileState:
path: Path
mtime_ns: int
size: int
inode: int
@dataclass(frozen=True)
class LogEvent:
kind: str
line_number: int
raw: str
timestamp_text: str | None = None
value: str | None = None
def to_dict(self) -> dict[str, str | int | None]:
return asdict(self)
@dataclass(frozen=True)
class SceneImport:
name: str
materials: int
textures: int
line_number: int
timestamp_text: str | None = None
def to_dict(self) -> dict[str, str | int | None]:
return asdict(self)
@dataclass
class NativeLogAnalysis:
scenes: dict[str, list[SceneImport]] = field(default_factory=dict)
events: list[LogEvent] = field(default_factory=list)
failed_files: list[LogEvent] = field(default_factory=list)
failed_import_scenes: list[str] = field(default_factory=list)
load_texture_to_material_failed: bool = False
import_scene_threw: bool = False
stage_without_new_stage: bool = False
tex_reclaim_count: int = 0
def latest_scene(self, name: str) -> SceneImport | None:
wanted = _scene_key(name)
for scene_name, imports in self.scenes.items():
if _scene_key(scene_name) == wanted and imports:
return imports[-1]
return None
def first_event(self, kind: str) -> LogEvent | None:
return next((event for event in self.events if event.kind == kind), None)
def to_summary(self) -> dict[str, object]:
summary: dict[str, object] = {
"scenes": {
name: [item.to_dict() for item in imports]
for name, imports in sorted(self.scenes.items())
},
"load_texture_to_material_failed": self.load_texture_to_material_failed,
"import_scene_threw": self.import_scene_threw,
"failed_import_scenes": self.failed_import_scenes,
"failed_files": [event.to_dict() for event in self.failed_files],
"stage_without_new_stage": self.stage_without_new_stage,
"tex_reclaim_count": self.tex_reclaim_count,
}
for output_key, scene_name in (
("whispering_vale", "WhisperingVale"),
("ricarten", "Ricarten"),
):
scene = self.latest_scene(scene_name)
if scene is not None:
summary[output_key] = {
"materials": scene.materials,
"textures": scene.textures,
}
if "whispering_vale" in summary:
whispering_vale = summary["whispering_vale"]
assert isinstance(whispering_vale, dict)
whispering_vale["load_texture_to_material_failed"] = (
self.load_texture_to_material_failed
)
whispering_vale["import_scene_failed"] = self.import_scene_threw or any(
"whisperingvale" in _scene_key(item)
for item in self.failed_import_scenes
)
return summary
def snapshot_native_logs(
log_directory: Path,
patterns: Iterable[str] = DEFAULT_NATIVE_LOG_PATTERNS,
) -> dict[str, LogFileState]:
states: dict[str, LogFileState] = {}
if not log_directory.is_dir():
return states
for pattern in patterns:
for path in log_directory.glob(pattern):
if not path.is_file():
continue
try:
state = _state(path)
except FileNotFoundError:
continue
states[_path_key(path)] = state
return states
def is_current_run_log(
state: LogFileState,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
) -> bool:
"""Return true only for a new log or a changed post-start existing log.
A path absent from the pre-launch baseline is new for this run. An existing
path must both change and have an mtime at or after the recorded test start.
The additional change check prevents a stale future-dated file from passing.
"""
previous = baseline.get(_path_key(state.path))
if previous is None:
return True
changed = (
state.mtime_ns != previous.mtime_ns
or state.size != previous.size
or state.inode != previous.inode
)
return changed and state.mtime_ns >= test_start_ns
def discover_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
patterns: Iterable[str] = DEFAULT_NATIVE_LOG_PATTERNS,
) -> list[LogFileState]:
current = snapshot_native_logs(log_directory, patterns)
eligible = [
state
for state in current.values()
if is_current_run_log(state, baseline, test_start_ns)
]
return sorted(eligible, key=lambda item: (item.mtime_ns, str(item.path)))
def newest_current_native_log(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
patterns: Iterable[str] = DEFAULT_NATIVE_LOG_PATTERNS,
) -> LogFileState | None:
logs = discover_current_native_logs(log_directory, baseline, test_start_ns, patterns)
return logs[-1] if logs else None
def parse_native_log(path: Path) -> NativeLogAnalysis:
with path.open("r", encoding="utf-8", errors="replace") as handle:
return parse_native_log_lines(handle)
def read_current_run_log_text(
state: LogFileState,
baseline: Mapping[str, LogFileState],
) -> str:
"""Read only bytes attributable to this run when a log path is reused."""
previous = baseline.get(_path_key(state.path))
offset = 0
if (
previous is not None
and state.inode == previous.inode
and state.size >= previous.size
):
offset = previous.size
with state.path.open("rb") as handle:
handle.seek(offset)
return handle.read().decode("utf-8", errors="replace")
def parse_current_run_log(
state: LogFileState,
baseline: Mapping[str, LogFileState],
) -> NativeLogAnalysis:
return parse_native_log_lines(read_current_run_log_text(state, baseline).splitlines())
def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
analysis = NativeLogAnalysis()
for line_number, raw_line in enumerate(lines, start=1):
raw = raw_line.rstrip("\r\n")
timestamp = _timestamp(raw)
scene_match = _SCENE_RE.search(raw)
if scene_match is not None:
scene = SceneImport(
name=scene_match.group("scene").strip(),
materials=int(scene_match.group("materials")),
textures=int(scene_match.group("textures")),
line_number=line_number,
timestamp_text=timestamp,
)
analysis.scenes.setdefault(scene.name, []).append(scene)
analysis.events.append(
LogEvent("scene_import", line_number, raw, timestamp, scene.name)
)
if "GRX::SurfaceManager::LoadTextureToMaterial" in raw:
analysis.load_texture_to_material_failed = True
analysis.events.append(
LogEvent("load_texture_to_material", line_number, raw, timestamp)
)
if "ImportScene threw" in raw:
analysis.import_scene_threw = True
analysis.events.append(LogEvent("import_scene_threw", line_number, raw, timestamp))
failed_import = _FAILED_IMPORT_RE.search(raw)
if failed_import is not None:
value = failed_import.group("scene").strip()
analysis.failed_import_scenes.append(value)
analysis.events.append(
LogEvent("failed_import_scene", line_number, raw, timestamp, value)
)
failed_file = _FAILED_FILE_RE.search(raw)
if failed_file is not None:
operation = failed_file.group("operation").casefold()
value = failed_file.group("path").strip()
event = LogEvent(f"failed_{operation}_file", line_number, raw, timestamp, value)
analysis.failed_files.append(event)
analysis.events.append(event)
if "Stage without new stage" in raw:
analysis.stage_without_new_stage = True
analysis.events.append(
LogEvent("stage_without_new_stage", line_number, raw, timestamp)
)
if "TexReclaim" in raw:
analysis.tex_reclaim_count += 1
analysis.events.append(LogEvent("tex_reclaim", line_number, raw, timestamp))
return analysis
def _state(path: Path) -> LogFileState:
stat = path.stat()
return LogFileState(path.absolute(), stat.st_mtime_ns, stat.st_size, stat.st_ino)
def _path_key(path: Path) -> str:
return str(path.absolute())
def _scene_key(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", value.casefold())
def _timestamp(line: str) -> str | None:
match = _TIMESTAMP_RE.match(line)
return match.group("timestamp") if match is not None else None

278
ptdiag/performance.py Normal file
View File

@@ -0,0 +1,278 @@
"""Low-overhead process and device sampling for performance experiments."""
from __future__ import annotations
import csv
from dataclasses import dataclass
import os
from pathlib import Path
import re
import time
from typing import Any, TextIO
_SAMPLE_FIELDS = (
"epoch",
"elapsed_seconds",
"process_cpu_percent",
"system_cpu_percent",
"rss_mib",
"read_mib_per_second",
"write_mib_per_second",
"voluntary_context_switches_per_second",
"nonvoluntary_context_switches_per_second",
"gpu_busy_percent",
"vram_busy_percent",
"gpu_sclk_mhz",
"gpu_mclk_mhz",
"cpu_average_mhz",
"cpu_minimum_mhz",
"cpu_maximum_mhz",
)
@dataclass(frozen=True)
class _Counters:
process_ticks: int
rss_bytes: int
read_bytes: int
write_bytes: int
voluntary_context_switches: int
nonvoluntary_context_switches: int
system_total_ticks: int
system_idle_ticks: int
class PerformanceSampler:
"""Sample one game process and the active AMD GPU at a fixed interval."""
def __init__(
self,
pid: int,
output: Path,
*,
interval_seconds: float = 0.5,
) -> None:
if pid <= 0:
raise ValueError("pid must be positive")
if interval_seconds <= 0:
raise ValueError("interval_seconds must be positive")
self.pid = pid
self.output = output
self.interval_seconds = interval_seconds
self._interval_ns = int(interval_seconds * 1_000_000_000)
self._started_ns = time.time_ns()
self._last_sample_ns: int | None = None
self._previous: _Counters | None = None
self._handle: TextIO = output.open("w", encoding="utf-8", newline="")
self._writer = csv.DictWriter(self._handle, fieldnames=_SAMPLE_FIELDS)
self._writer.writeheader()
self._gpu_device = _discover_gpu_device()
self._sample_count = 0
self._peaks: dict[str, float] = {}
self._first_counters: _Counters | None = None
self._last_counters: _Counters | None = None
def maybe_sample(self, now_ns: int | None = None) -> bool:
now_ns = now_ns if now_ns is not None else time.time_ns()
previous_sample_ns = self._last_sample_ns
if (
previous_sample_ns is not None
and now_ns - previous_sample_ns < self._interval_ns
):
return False
self._last_sample_ns = now_ns
try:
counters = _read_counters(self.pid)
except (OSError, ValueError):
return False
if self._first_counters is None:
self._first_counters = counters
self._last_counters = counters
row = self._sample_row(now_ns, previous_sample_ns, counters)
self._writer.writerow(row)
self._handle.flush()
self._sample_count += 1
for key, value in row.items():
if key in {"epoch", "elapsed_seconds"} or not isinstance(value, (int, float)):
continue
self._peaks[key] = max(float(value), self._peaks.get(key, float("-inf")))
self._previous = counters
return True
def close(self) -> dict[str, Any]:
if not self._handle.closed:
self._handle.close()
first = self._first_counters
last = self._last_counters
return {
"pid": self.pid,
"output": str(self.output),
"interval_seconds": self.interval_seconds,
"sample_count": self._sample_count,
"gpu_device": str(self._gpu_device) if self._gpu_device else None,
"peaks": dict(sorted(self._peaks.items())),
"process_io_bytes": {
"read": max(0, last.read_bytes - first.read_bytes) if first and last else None,
"write": max(0, last.write_bytes - first.write_bytes) if first and last else None,
},
}
def _sample_row(
self,
now_ns: int,
previous_sample_ns: int | None,
counters: _Counters,
) -> dict[str, float | str | None]:
elapsed_seconds = (now_ns - self._started_ns) / 1_000_000_000
row: dict[str, float | str | None] = {
"epoch": now_ns / 1_000_000_000,
"elapsed_seconds": elapsed_seconds,
"process_cpu_percent": None,
"system_cpu_percent": None,
"rss_mib": counters.rss_bytes / 1024**2,
"read_mib_per_second": None,
"write_mib_per_second": None,
"voluntary_context_switches_per_second": None,
"nonvoluntary_context_switches_per_second": None,
"gpu_busy_percent": _read_float(self._gpu_device, "gpu_busy_percent"),
"vram_busy_percent": _read_float(self._gpu_device, "mem_busy_percent"),
"gpu_sclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_sclk"),
"gpu_mclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_mclk"),
"cpu_average_mhz": None,
"cpu_minimum_mhz": None,
"cpu_maximum_mhz": None,
}
frequencies = _read_cpu_frequencies_mhz()
if frequencies:
row["cpu_average_mhz"] = sum(frequencies) / len(frequencies)
row["cpu_minimum_mhz"] = min(frequencies)
row["cpu_maximum_mhz"] = max(frequencies)
previous = self._previous
if previous is None or previous_sample_ns is None:
return row
delta_seconds = max((now_ns - previous_sample_ns) / 1_000_000_000, 0.001)
clock_ticks = os.sysconf("SC_CLK_TCK")
row["process_cpu_percent"] = (
max(0, counters.process_ticks - previous.process_ticks)
/ clock_ticks
/ delta_seconds
* 100
)
system_total = counters.system_total_ticks - previous.system_total_ticks
system_idle = counters.system_idle_ticks - previous.system_idle_ticks
if system_total > 0:
row["system_cpu_percent"] = max(0, system_total - system_idle) / system_total * 100
row["read_mib_per_second"] = (
max(0, counters.read_bytes - previous.read_bytes) / 1024**2 / delta_seconds
)
row["write_mib_per_second"] = (
max(0, counters.write_bytes - previous.write_bytes) / 1024**2 / delta_seconds
)
row["voluntary_context_switches_per_second"] = (
max(
0,
counters.voluntary_context_switches - previous.voluntary_context_switches,
)
/ delta_seconds
)
row["nonvoluntary_context_switches_per_second"] = (
max(
0,
counters.nonvoluntary_context_switches
- previous.nonvoluntary_context_switches,
)
/ delta_seconds
)
return row
def _read_counters(pid: int) -> _Counters:
root = Path("/proc") / str(pid)
process_ticks, rss_bytes = _parse_proc_stat((root / "stat").read_text(encoding="utf-8"))
io = _parse_key_values((root / "io").read_text(encoding="utf-8"))
status = _parse_key_values((root / "status").read_text(encoding="utf-8"))
system_total, system_idle = _parse_system_cpu(Path("/proc/stat").read_text(encoding="utf-8"))
return _Counters(
process_ticks=process_ticks,
rss_bytes=rss_bytes,
read_bytes=io.get("read_bytes", 0),
write_bytes=io.get("write_bytes", 0),
voluntary_context_switches=status.get("voluntary_ctxt_switches", 0),
nonvoluntary_context_switches=status.get("nonvoluntary_ctxt_switches", 0),
system_total_ticks=system_total,
system_idle_ticks=system_idle,
)
def _parse_proc_stat(value: str) -> tuple[int, int]:
closing = value.rfind(")")
if closing < 0:
raise ValueError("Malformed /proc process stat")
fields = value[closing + 2 :].split()
if len(fields) < 22:
raise ValueError("Truncated /proc process stat")
process_ticks = int(fields[11]) + int(fields[12])
rss_bytes = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
return process_ticks, rss_bytes
def _parse_key_values(value: str) -> dict[str, int]:
result: dict[str, int] = {}
for line in value.splitlines():
key, separator, raw = line.partition(":")
if not separator:
continue
match = re.match(r"\s*([0-9]+)", raw)
if match:
result[key] = int(match.group(1))
return result
def _parse_system_cpu(value: str) -> tuple[int, int]:
first = value.splitlines()[0].split()
if not first or first[0] != "cpu" or len(first) < 5:
raise ValueError("Malformed /proc/stat CPU line")
values = [int(item) for item in first[1:]]
return sum(values), values[3] + (values[4] if len(values) > 4 else 0)
def _discover_gpu_device() -> Path | None:
for path in sorted(Path("/sys/class/drm").glob("card*/device")):
if (path / "gpu_busy_percent").is_file():
return path
return None
def _read_float(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
return float((root / name).read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def _read_active_mhz(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
value = (root / name).read_text(encoding="utf-8")
except OSError:
return None
return _parse_active_mhz(value)
def _parse_active_mhz(value: str) -> float | None:
match = re.search(r"([0-9]+)\s*Mhz\s*\*", value, flags=re.IGNORECASE)
return float(match.group(1)) if match else None
def _read_cpu_frequencies_mhz() -> list[float]:
result: list[float] = []
for path in sorted(Path("/sys/devices/system/cpu").glob("cpu[0-9]*/cpufreq/scaling_cur_freq")):
try:
result.append(float(path.read_text(encoding="utf-8").strip()) / 1000)
except (OSError, ValueError):
continue
return result

65
ptdiag/prefix.py Normal file
View File

@@ -0,0 +1,65 @@
"""Guarded helpers for the dedicated Proton test prefix."""
from __future__ import annotations
from pathlib import Path
from .config import AppConfig
from .runner import CommandLogger, CommandResult
class PrefixSafetyError(RuntimeError):
"""Raised when a cleanup target is not provably the dedicated test prefix."""
def validate_dedicated_prefix(config: AppConfig) -> Path:
prefix = config.proton7.prefix.resolve(strict=False)
compat = config.proton7.compat.resolve(strict=False)
live_prefix = config.paths.live_prefix.resolve(strict=False)
if prefix == live_prefix:
raise PrefixSafetyError("Dedicated prefix resolves to the live launcher prefix")
try:
prefix.relative_to(compat)
except ValueError as exc:
raise PrefixSafetyError(
f"Dedicated prefix {prefix} is not inside compat directory {compat}"
) from exc
if prefix == compat:
raise PrefixSafetyError("Dedicated prefix must be a child of the compat directory")
return prefix
def resolve_proton_wineserver(config: AppConfig) -> Path:
candidates = proton_wineserver_candidates(config)
for candidate in candidates:
if candidate.is_file():
return candidate
raise FileNotFoundError(
"Proton wineserver was not found in dist/bin or files/bin under "
f"{config.proton7.proton.parent}"
)
def proton_wineserver_candidates(config: AppConfig) -> tuple[Path, Path]:
proton_directory = config.proton7.proton.parent
return (
proton_directory / "dist/bin/wineserver",
proton_directory / "files/bin/wineserver",
)
def cleanup_dedicated_wineserver(
config: AppConfig,
command_logger: CommandLogger,
*,
timeout_seconds: float = 15.0,
dry_run: bool = False,
) -> CommandResult:
prefix = validate_dedicated_prefix(config)
wineserver = resolve_proton_wineserver(config)
return command_logger.run(
[str(wineserver), "-k"],
env_overrides={"WINEPREFIX": str(prefix), "WINEDEBUG": "-all"},
timeout_seconds=timeout_seconds,
dry_run=dry_run,
)

105
ptdiag/process.py Normal file
View File

@@ -0,0 +1,105 @@
"""Read-only host and configured environment inspection."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import os
from pathlib import Path
import platform
import shutil
import sys
from typing import Any
from .config import AppConfig
from .prefix import proton_wineserver_candidates
@dataclass(frozen=True)
class EnvironmentCheck:
name: str
ok: bool
value: str
required: bool = True
def to_dict(self) -> dict[str, str | bool]:
return asdict(self)
def collect_environment(config: AppConfig) -> dict[str, Any]:
checks = environment_checks(config)
return {
"collected_at": datetime.now().astimezone().isoformat(),
"host": {
"platform": platform.platform(),
"machine": platform.machine(),
"python": sys.version,
"executable": sys.executable,
"cwd": os.getcwd(),
},
"config": config.to_dict(),
"checks": [check.to_dict() for check in checks],
"ok": all(check.ok for check in checks if check.required),
}
def environment_checks(config: AppConfig) -> list[EnvironmentCheck]:
checks = [
_path_check("app_root", config.paths.app_root, "directory"),
_path_check("live_prefix", config.paths.live_prefix, "directory"),
_path_check("live_game", config.paths.live_game, "directory"),
_path_check("logs", config.paths.logs, "directory"),
_path_check("native_logs", config.paths.native_logs, "directory"),
_path_check("proton7", config.proton7.proton, "file"),
_any_path_check("proton7_wineserver", proton_wineserver_candidates(config)),
_path_check("steam_root", config.proton7.steam_root, "directory"),
_path_check("compat", config.proton7.compat, "directory"),
_path_check("dedicated_prefix", config.proton7.prefix, "directory"),
_path_check("game_exe", config.paths.live_game / config.game.exe, "file"),
EnvironmentCheck(
"game_argument",
config.game.args == ("PTEU_REBORN",),
" ".join(config.game.args),
),
EnvironmentCheck(
"virtual_desktop",
config.game.virtual_desktop == "1600x900",
config.game.virtual_desktop,
),
_tool_check("strace", required=False),
_tool_check("gdb", required=False),
_i386_vulkan_check(),
]
return checks
def _path_check(name: str, path: Path, kind: str) -> EnvironmentCheck:
ok = path.is_file() if kind == "file" else path.is_dir()
return EnvironmentCheck(name, ok, str(path))
def _tool_check(name: str, required: bool) -> EnvironmentCheck:
path = shutil.which(name)
return EnvironmentCheck(name, path is not None, path or "not found", required)
def _any_path_check(name: str, candidates: tuple[Path, ...]) -> EnvironmentCheck:
found = next((path for path in candidates if path.is_file()), None)
return EnvironmentCheck(
name,
found is not None,
str(found) if found is not None else "not found: " + ", ".join(map(str, candidates)),
)
def _i386_vulkan_check() -> EnvironmentCheck:
candidates = (
Path("/usr/lib/i386-linux-gnu/libvulkan.so.1"),
Path("/usr/lib/i386-linux-gnu/libvulkan.so"),
)
found = next((path for path in candidates if path.exists()), None)
return EnvironmentCheck(
"i386_vulkan_loader",
found is not None,
str(found) if found is not None else "not found",
)

32
ptdiag/proton_compat.py Normal file
View File

@@ -0,0 +1,32 @@
"""Run the retained Proton 7 script safely on Python 3.14.
Proton 7 predates Python's tar extraction-filter API. The known-good v41
runtime used this compatibility shim; keeping it in the harness makes the
exact launch recipe inspectable and reproducible.
"""
from __future__ import annotations
from pathlib import Path
import runpy
import sys
import tarfile
from typing import Sequence
def main(argv: Sequence[str] | None = None) -> None:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments:
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
proton = Path(arguments[0]).expanduser().resolve(strict=True)
proton_directory = str(proton.parent)
if proton_directory not in sys.path:
sys.path.insert(0, proton_directory)
if hasattr(tarfile, "fully_trusted_filter"):
tarfile.TarFile.extraction_filter = staticmethod(tarfile.fully_trusted_filter)
sys.argv = [str(proton), *arguments[1:]]
runpy.run_path(str(proton), run_name="__main__")
if __name__ == "__main__":
main()

100
ptdiag/registry.py Normal file
View File

@@ -0,0 +1,100 @@
"""Read-only Wine registry snapshots for reversible desktop configuration."""
from __future__ import annotations
import ast
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from .filesystem import sha256_file
_VALUE_RE = re.compile(r'^(?P<name>"(?:\\.|[^"\\])*")=(?P<value>.*)$')
_SECTION_RE = re.compile(r"^\[(?P<name>.*)\](?:\s+[0-9]+)?$")
@dataclass(frozen=True)
class RegistryValue:
exists: bool
value: str | None
def to_dict(self) -> dict[str, str | bool | None]:
return asdict(self)
@dataclass(frozen=True)
class DesktopRegistrySnapshot:
user_reg: Path
sha256: str
desktop_name: str
selected_desktop: RegistryValue
desktop_size: RegistryValue
def to_dict(self) -> dict[str, object]:
return {
"user_reg": str(self.user_reg),
"sha256": self.sha256,
"desktop_name": self.desktop_name,
"selected_desktop": self.selected_desktop.to_dict(),
"desktop_size": self.desktop_size.to_dict(),
}
def snapshot_desktop_registry(prefix: Path, desktop_name: str) -> DesktopRegistrySnapshot:
user_reg = prefix / "user.reg"
sections = read_registry_string_values(user_reg)
selected = sections.get(r"Software\Wine\Explorer", {}).get("Desktop")
size = sections.get(r"Software\Wine\Explorer\Desktops", {}).get(desktop_name)
return DesktopRegistrySnapshot(
user_reg=user_reg,
sha256=sha256_file(user_reg),
desktop_name=desktop_name,
selected_desktop=RegistryValue(selected is not None, selected),
desktop_size=RegistryValue(size is not None, size),
)
def desktop_registry_restored(
before: DesktopRegistrySnapshot,
after: DesktopRegistrySnapshot,
) -> bool:
return (
before.desktop_name == after.desktop_name
and before.selected_desktop == after.selected_desktop
and before.desktop_size == after.desktop_size
)
def read_registry_string_values(path: Path) -> dict[str, dict[str, str]]:
sections: dict[str, dict[str, str]] = {}
current: dict[str, str] | None = None
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw in handle:
line = raw.rstrip("\r\n")
section = _SECTION_RE.match(line)
if section is not None:
name = section.group("name").replace(r"\\", "\\")
current = sections.setdefault(name, {})
continue
if current is None:
continue
match = _VALUE_RE.match(line)
if match is None:
continue
name = _decode_string(match.group("name"))
value_text = match.group("value")
if name is None or not value_text.startswith('"'):
continue
value = _decode_string(value_text)
if value is not None:
current[name] = value
return sections
def _decode_string(value: str) -> str | None:
try:
result = ast.literal_eval(value)
except (SyntaxError, ValueError):
return None
return result if isinstance(result, str) else None

71
ptdiag/result.py Normal file
View File

@@ -0,0 +1,71 @@
"""Unique result-directory creation and JSON output."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import re
from typing import Any, Mapping
@dataclass(frozen=True)
class ResultDirectory:
path: Path
metadata_path: Path
commands_path: Path
def create_result_directory(
root: Path,
test_name: str,
started_at: datetime | None = None,
) -> ResultDirectory:
name = _slug(test_name)
started = started_at or datetime.now().astimezone()
base_name = f"{started:%Y%m%d-%H%M%S}-{name}"
root.mkdir(parents=True, exist_ok=True)
path = _mkdir_unique(root, base_name)
metadata_path = path / "metadata.json"
commands_path = path / "commands.log"
commands_path.touch(exist_ok=False)
write_json(
metadata_path,
{
"test": name,
"started_at": started.isoformat(),
"result_directory": str(path),
},
)
return ResultDirectory(path, metadata_path, commands_path)
def write_json(path: Path, value: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
temporary.replace(path)
def _slug(value: str) -> str:
result = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
if not result:
raise ValueError("test_name must contain a letter or digit")
return result
def _mkdir_unique(root: Path, base_name: str) -> Path:
for index in range(1000):
suffix = "" if index == 0 else f"-{index:02d}"
candidate = root / f"{base_name}{suffix}"
try:
candidate.mkdir()
except FileExistsError:
continue
return candidate
raise FileExistsError(f"Could not allocate a unique result directory for {base_name}")

164
ptdiag/runner.py Normal file
View File

@@ -0,0 +1,164 @@
"""Structured command logging with argument-array subprocess execution."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import subprocess
from typing import Mapping, Sequence
@dataclass(frozen=True)
class CommandResult:
argv: tuple[str, ...]
returncode: int | None
stdout: str
stderr: str
timed_out: bool
dry_run: bool
error: str | None = None
class CommandLogger:
def __init__(self, path: Path) -> None:
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
def run(
self,
argv: Sequence[str],
*,
cwd: Path | None = None,
env_overrides: Mapping[str, str] | None = None,
timeout_seconds: float,
dry_run: bool = False,
) -> CommandResult:
arguments = _validate_argv(argv)
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
overrides = dict(env_overrides or {})
started_at = datetime.now().astimezone()
if dry_run:
result = CommandResult(arguments, None, "", "", False, True)
self._append(started_at, cwd, overrides, timeout_seconds, result)
return result
environment = os.environ.copy()
environment.update(overrides)
try:
completed = subprocess.run(
list(arguments),
cwd=cwd,
env=environment,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
result = CommandResult(
arguments,
completed.returncode,
completed.stdout,
completed.stderr,
False,
False,
)
except subprocess.TimeoutExpired as exc:
result = CommandResult(
arguments,
None,
_decode_timeout_stream(exc.stdout),
_decode_timeout_stream(exc.stderr),
True,
False,
)
except OSError as exc:
result = CommandResult(
arguments,
None,
"",
"",
False,
False,
str(exc),
)
self._append(started_at, cwd, overrides, timeout_seconds, result)
return result
def record_lifecycle(
self,
argv: Sequence[str],
*,
state: str,
cwd: Path | None,
env_overrides: Mapping[str, str],
timeout_seconds: float,
started_at: datetime,
pid: int | None = None,
returncode: int | None = None,
timed_out: bool = False,
error: str | None = None,
) -> None:
arguments = _validate_argv(argv)
record = {
"argv": list(arguments),
"cwd": str(cwd) if cwd is not None else None,
"env_overrides": dict(sorted(env_overrides.items())),
"error": error,
"observed_at": datetime.now().astimezone().isoformat(),
"pid": pid,
"returncode": returncode,
"started_at": started_at.isoformat(),
"state": state,
"timed_out": timed_out,
"timeout_seconds": timeout_seconds,
}
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True))
handle.write("\n")
def _append(
self,
started_at: datetime,
cwd: Path | None,
env_overrides: Mapping[str, str],
timeout_seconds: float,
result: CommandResult,
) -> None:
record = {
"argv": list(result.argv),
"cwd": str(cwd) if cwd is not None else None,
"dry_run": result.dry_run,
"env_overrides": dict(sorted(env_overrides.items())),
"error": result.error,
"finished_at": datetime.now().astimezone().isoformat(),
"returncode": result.returncode,
"started_at": started_at.isoformat(),
"stderr": result.stderr,
"stdout": result.stdout,
"timed_out": result.timed_out,
"timeout_seconds": timeout_seconds,
}
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True))
handle.write("\n")
def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]:
if isinstance(argv, (str, bytes)):
raise TypeError("argv must be a sequence of strings, not a shell command")
arguments = tuple(argv)
if not arguments or not all(isinstance(argument, str) and argument for argument in arguments):
raise ValueError("argv must contain non-empty strings")
return arguments
def _decode_timeout_stream(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value

331
ptdiag/testcase.py Normal file
View File

@@ -0,0 +1,331 @@
"""Typed testcase loading with recursive TOML inheritance."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping
import tomllib
from .config import project_root
class TestcaseError(ValueError):
"""Raised when an experiment testcase is missing or invalid."""
@dataclass(frozen=True)
class TestGame:
args: tuple[str, ...]
cwd: str
@dataclass(frozen=True)
class TestDisplay:
virtual_desktop: bool
size: str
@dataclass(frozen=True)
class TestWatch:
require_new_native_log: bool
patterns: tuple[str, ...]
@dataclass(frozen=True)
class TestCapture:
process_maps: bool
cwd: bool
environment: bool
dll_overrides: bool
hashes: bool
dxvk_log: bool
native_log: bool
strace_files: bool
performance_stats: bool
focus_before_seconds: float
focus_after_seconds: float
@dataclass(frozen=True)
class TestIniOverride:
path: str
section: str
key: str
expected: str
value: str
@dataclass(frozen=True)
class TestModification:
kind: str
source: str
destination: str
texture_indices: tuple[int, ...]
ini_overrides: tuple[TestIniOverride, ...]
@dataclass(frozen=True)
class Testcase:
name: str
description: str
runner: str
timeout_seconds: float
stop_after_failure_seconds: float
game: TestGame
display: TestDisplay
env: dict[str, str]
watch: TestWatch
capture: TestCapture
modification: TestModification | None
source: Path
sources: tuple[Path, ...]
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
result["source"] = str(self.source)
result["sources"] = [str(path) for path in self.sources]
return result
def resolve_testcase_path(value: str | Path) -> Path:
path = Path(value)
if path.suffix or path.parent != Path("."):
return path.expanduser().absolute()
return (project_root() / "testcases" / f"{path.name}.toml").absolute()
def load_testcase(value: str | Path) -> Testcase:
path = resolve_testcase_path(value)
data, sources = _load_inherited(path, ())
game = _section(data, "game")
display = _section(data, "display")
environment = _section(data, "env")
watch = _section(data, "watch")
capture = _section(data, "capture")
modification = _optional_section(data, "modification")
return Testcase(
name=_string(data, "name"),
description=_string(data, "description"),
runner=_string(data, "runner"),
timeout_seconds=_positive_number(data, "timeout_seconds"),
stop_after_failure_seconds=_nonnegative_number(
data, "stop_after_failure_seconds"
),
game=TestGame(
args=_string_tuple(game, "args"),
cwd=_string(game, "cwd"),
),
display=TestDisplay(
virtual_desktop=_boolean(display, "virtual_desktop"),
size=_string(display, "size"),
),
env=_string_mapping(environment, "env"),
watch=TestWatch(
require_new_native_log=_boolean(watch, "require_new_native_log"),
patterns=_string_tuple(watch, "patterns"),
),
capture=TestCapture(
process_maps=_boolean(capture, "process_maps"),
cwd=_boolean(capture, "cwd"),
environment=_boolean(capture, "environment"),
dll_overrides=_boolean(capture, "dll_overrides"),
hashes=_boolean(capture, "hashes"),
dxvk_log=_boolean(capture, "dxvk_log"),
native_log=_boolean(capture, "native_log"),
strace_files=_boolean(capture, "strace_files"),
performance_stats=_optional_boolean(capture, "performance_stats", False),
focus_before_seconds=_optional_nonnegative_number(
capture, "focus_before_seconds", 4.0
),
focus_after_seconds=_optional_nonnegative_number(
capture, "focus_after_seconds", 1.0
),
),
modification=(
TestModification(
kind=_string(modification, "kind"),
source=_string(modification, "source"),
destination=_string(modification, "destination"),
texture_indices=_optional_integer_tuple(
modification,
"texture_indices",
),
ini_overrides=_optional_ini_overrides(modification),
)
if modification is not None
else None
),
source=path,
sources=sources,
)
def _load_inherited(
path: Path,
stack: tuple[Path, ...],
) -> tuple[dict[str, Any], tuple[Path, ...]]:
resolved = path.resolve(strict=False)
if resolved in stack:
chain = " -> ".join(str(item) for item in (*stack, resolved))
raise TestcaseError(f"Testcase inheritance cycle: {chain}")
try:
with path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise TestcaseError(f"Testcase not found: {path}") from exc
except tomllib.TOMLDecodeError as exc:
raise TestcaseError(f"Invalid TOML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise TestcaseError(f"Testcase root must be a table: {path}")
parent_name = data.pop("extends", None)
if parent_name is None:
return data, (resolved,)
if not isinstance(parent_name, str) or not parent_name.strip():
raise TestcaseError(f"extends must be a non-empty string in {path}")
parent_file = Path(parent_name)
if parent_file.suffix == "":
parent_file = parent_file.with_suffix(".toml")
if not parent_file.is_absolute():
parent_file = path.parent / parent_file
parent, sources = _load_inherited(parent_file, (*stack, resolved))
return _merge(parent, data), (*sources, resolved)
def _merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = dict(base)
for key, value in override.items():
current = result.get(key)
if isinstance(current, Mapping) and isinstance(value, Mapping):
result[key] = _merge(current, value)
else:
result[key] = value
return result
def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
value = data.get(name)
if not isinstance(value, Mapping):
raise TestcaseError(f"Missing or invalid [{name}] section")
return value
def _optional_section(data: Mapping[str, Any], name: str) -> Mapping[str, Any] | None:
value = data.get(name)
if value is None:
return None
if not isinstance(value, Mapping):
raise TestcaseError(f"Invalid [{name}] section")
return value
def _string(data: Mapping[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise TestcaseError(f"{key} must be a non-empty string")
return value.strip()
def _boolean(data: Mapping[str, Any], key: str) -> bool:
value = data.get(key)
if not isinstance(value, bool):
raise TestcaseError(f"{key} must be true or false")
return value
def _optional_boolean(data: Mapping[str, Any], key: str, default: bool) -> bool:
if key not in data:
return default
return _boolean(data, key)
def _string_tuple(data: Mapping[str, Any], key: str) -> tuple[str, ...]:
value = data.get(key)
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise TestcaseError(f"{key} must be a non-empty array of strings")
return tuple(value)
def _optional_integer_tuple(data: Mapping[str, Any], key: str) -> tuple[int, ...]:
value = data.get(key, [])
if not isinstance(value, list) or not all(
isinstance(item, int) and not isinstance(item, bool) and item >= 0
for item in value
):
raise TestcaseError(f"{key} must be an array of nonnegative integers")
if len(value) != len(set(value)):
raise TestcaseError(f"{key} must not contain duplicates")
return tuple(value)
def _optional_ini_overrides(data: Mapping[str, Any]) -> tuple[TestIniOverride, ...]:
value = data.get("ini_overrides", [])
if not isinstance(value, list):
raise TestcaseError("ini_overrides must be an array of tables")
result: list[TestIniOverride] = []
identities: set[tuple[str, str, str]] = set()
for item in value:
if not isinstance(item, Mapping):
raise TestcaseError("ini_overrides must contain only tables")
override = TestIniOverride(
path=_string(item, "path"),
section=_string(item, "section"),
key=_string(item, "key"),
expected=_string(item, "expected"),
value=_string(item, "value"),
)
identity = (
override.path.casefold(),
override.section.casefold(),
override.key.casefold(),
)
if identity in identities:
raise TestcaseError("ini_overrides must not contain duplicate keys")
if override.expected == override.value:
raise TestcaseError("INI expected and replacement values must differ")
identities.add(identity)
result.append(override)
return tuple(result)
def _string_mapping(data: Mapping[str, Any], name: str) -> dict[str, str]:
result: dict[str, str] = {}
for key, value in data.items():
if not isinstance(key, str) or not isinstance(value, str):
raise TestcaseError(f"[{name}] must contain only string values")
result[key] = value
return result
def _positive_number(data: Mapping[str, Any], key: str) -> float:
value = _number(data, key)
if value <= 0:
raise TestcaseError(f"{key} must be positive")
return value
def _nonnegative_number(data: Mapping[str, Any], key: str) -> float:
value = _number(data, key)
if value < 0:
raise TestcaseError(f"{key} must be nonnegative")
return value
def _optional_nonnegative_number(
data: Mapping[str, Any],
key: str,
default: float,
) -> float:
if key not in data:
return default
return _nonnegative_number(data, key)
def _number(data: Mapping[str, Any], key: str) -> float:
value = data.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TestcaseError(f"{key} must be a number")
return float(value)

242
ptdiag/trace.py Normal file
View File

@@ -0,0 +1,242 @@
"""Streaming merge and normalization for per-PID strace file traces."""
from __future__ import annotations
import ast
from collections import Counter
from contextlib import ExitStack
from dataclasses import asdict, dataclass
import heapq
import json
from pathlib import Path
import re
from typing import Iterator, TextIO
_TIMESTAMP_RE = re.compile(r"^(?P<timestamp>[0-9]+(?:\.[0-9]+)?)\s+(?P<body>.*)$")
_SYSCALL_RE = re.compile(r"^(?P<syscall>[A-Za-z_][A-Za-z0-9_]*)\(")
_ERRNO_RE = re.compile(
r"=\s+-1\s+(?P<errno>E[A-Z0-9]+)\s+\((?P<description>[^)]*)\)"
)
_QUOTED_RE = re.compile(r'"(?:\\.|[^"\\])*"')
_ERROR_CLASSIFICATIONS = {
"ENOENT": "missing",
"ENOTDIR": "path-component-not-directory",
"EACCES": "permission-denied",
"EPERM": "operation-not-permitted",
"ELOOP": "symlink-loop",
"ENAMETOOLONG": "name-too-long",
"EROFS": "read-only-filesystem",
"EIO": "io-error",
}
@dataclass(frozen=True)
class TraceSummary:
shards: int
lines: int
normalized_records: int
focused_lines: int
error_counts: dict[str, int]
top_errors: list[dict[str, object]]
merged_path: Path
normalized_path: Path
focused_path: Path
focused_json_path: Path
def to_dict(self) -> dict[str, object]:
result = asdict(self)
for key in (
"merged_path",
"normalized_path",
"focused_path",
"focused_json_path",
):
result[key] = str(result[key])
return result
def process_trace_shards(
raw_directory: Path,
result_directory: Path,
*,
trigger_epoch: float,
focus_before_seconds: float,
focus_after_seconds: float,
live_game: Path,
) -> TraceSummary:
shards = sorted(
(path for path in raw_directory.glob("trace.*") if path.is_file()),
key=_shard_pid,
)
merged_path = result_directory / "merged.txt"
normalized_path = result_directory / "filesystem.jsonl"
focused_path = result_directory / "focused.txt"
focused_json_path = result_directory / "focused.jsonl"
error_counts: Counter[str] = Counter()
error_paths: Counter[tuple[str, str, str, str | None]] = Counter()
line_count = 0
normalized_count = 0
focused_count = 0
focus_start = trigger_epoch - focus_before_seconds
focus_end = trigger_epoch + focus_after_seconds
with (
merged_path.open("w", encoding="utf-8") as merged,
normalized_path.open("w", encoding="utf-8") as normalized,
focused_path.open("w", encoding="utf-8") as focused,
focused_json_path.open("w", encoding="utf-8") as focused_json,
):
for timestamp, pid, raw in _merge_shards(shards):
line_count += 1
body = _body(raw)
merged_line = f"{timestamp:.6f} [{pid}] {body}\n"
merged.write(merged_line)
record = parse_trace_line(pid, raw, live_game)
if record is not None:
normalized_count += 1
normalized.write(json.dumps(record, sort_keys=True))
normalized.write("\n")
errno = record.get("errno")
classification = record.get("error_classification")
if isinstance(errno, str) and isinstance(classification, str):
error_counts[classification] += 1
paths = record.get("paths")
path = paths[0] if isinstance(paths, list) and paths else ""
game_relative = record.get("game_relative")
error_paths[
(
classification,
errno,
str(path),
str(game_relative) if game_relative is not None else None,
)
] += 1
if focus_start <= timestamp <= focus_end:
focused_count += 1
focused.write(merged_line)
if record is not None:
focused_json.write(json.dumps(record, sort_keys=True))
focused_json.write("\n")
top_errors = [
{
"classification": key[0],
"errno": key[1],
"path": key[2],
"game_relative": key[3],
"count": count,
}
for key, count in error_paths.most_common(200)
]
return TraceSummary(
shards=len(shards),
lines=line_count,
normalized_records=normalized_count,
focused_lines=focused_count,
error_counts=dict(sorted(error_counts.items())),
top_errors=top_errors,
merged_path=merged_path,
normalized_path=normalized_path,
focused_path=focused_path,
focused_json_path=focused_json_path,
)
def parse_trace_line(pid: int, line: str, live_game: Path) -> dict[str, object] | None:
match = _TIMESTAMP_RE.match(line.rstrip("\n"))
if match is None:
return None
timestamp = float(match.group("timestamp"))
body = match.group("body")
syscall_match = _SYSCALL_RE.match(body)
if syscall_match is None:
return None
paths = [_decode_quoted(item) for item in _QUOTED_RE.findall(body)]
paths = [item for item in paths if item is not None]
errno_match = _ERRNO_RE.search(body)
errno = errno_match.group("errno") if errno_match is not None else None
classification = (
_ERROR_CLASSIFICATIONS.get(errno, "other-error") if errno is not None else None
)
game_relative = next(
(
_relative_game_path(path, live_game)
for path in paths
if _relative_game_path(path, live_game) is not None
),
None,
)
return {
"timestamp": timestamp,
"pid": pid,
"syscall": syscall_match.group("syscall"),
"paths": paths,
"errno": errno,
"error_classification": classification,
"game_relative": game_relative,
"raw": body,
}
def _merge_shards(paths: list[Path]) -> Iterator[tuple[float, int, str]]:
with ExitStack() as stack:
iterators: list[Iterator[tuple[float, int, str]]] = []
for path in paths:
handle = stack.enter_context(path.open("r", encoding="utf-8", errors="replace"))
iterators.append(_iter_shard(handle, _shard_pid(path)))
heap: list[tuple[float, int, int, str]] = []
for index, iterator in enumerate(iterators):
try:
timestamp, pid, raw = next(iterator)
except StopIteration:
continue
heapq.heappush(heap, (timestamp, pid, index, raw))
while heap:
timestamp, pid, index, raw = heapq.heappop(heap)
yield timestamp, pid, raw
try:
next_timestamp, next_pid, next_raw = next(iterators[index])
except StopIteration:
continue
heapq.heappush(heap, (next_timestamp, next_pid, index, next_raw))
def _iter_shard(handle: TextIO, pid: int) -> Iterator[tuple[float, int, str]]:
last_timestamp = 0.0
for raw in handle:
match = _TIMESTAMP_RE.match(raw)
if match is not None:
last_timestamp = float(match.group("timestamp"))
yield last_timestamp, pid, raw
def _shard_pid(path: Path) -> int:
try:
return int(path.name.rsplit(".", 1)[1])
except (IndexError, ValueError):
return 0
def _body(raw: str) -> str:
match = _TIMESTAMP_RE.match(raw.rstrip("\n"))
return match.group("body") if match is not None else raw.rstrip("\n")
def _decode_quoted(value: str) -> str | None:
try:
decoded = ast.literal_eval(value)
except (SyntaxError, ValueError):
return None
return decoded if isinstance(decoded, str) else None
def _relative_game_path(value: str, live_game: Path) -> str | None:
normalized = value.replace("\\", "/")
root = str(live_game).replace("\\", "/").rstrip("/")
index = normalized.casefold().find(root.casefold())
if index < 0:
return None
relative = normalized[index + len(root) :].lstrip("/")
return relative or "."

7
ptlinux/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Portable PristonTale EU installer and Linux launcher."""
from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.5.1"

4
ptlinux/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
from .cli import main
raise SystemExit(main())

651
ptlinux/cli.py Normal file
View File

@@ -0,0 +1,651 @@
"""Command-line interface for installation, updates, repair, and play."""
from __future__ import annotations
import argparse
from contextlib import AbstractContextManager, nullcontext
from dataclasses import asdict
from datetime import datetime
import json
from pathlib import Path
import sys
from typing import Sequence
from . import __version__
from .command import CommandError, CommandRunner
from .depot import SyncProgress, sync_managed_game
from .doctor import inspect_host
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
from .importer import GameImportResult, discover_existing_game, import_existing_game
from .launcher import (
LauncherError,
configure_launcher_prefix,
find_wine_tools,
launch_official_updater,
prepare_launcher_binary,
)
from .locking import launcher_operation_lock
from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view
from .paths import AppPaths
from .runtime import (
prepare_game_runtime,
run_game,
select_runtime,
)
from .settings import Settings, ensure_settings, load_settings, save_settings
from .self_update import (
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
update_check_to_dict,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pristontale-eu",
description="PristonTale EU all-in-one Linux installer and clean launcher",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("gui", help="open the native Linux dashboard")
subparsers.add_parser("doctor", help="check cross-distribution dependencies")
fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher")
fetch.add_argument("--dry-run", action="store_true")
setup = subparsers.add_parser("setup", help="configure Wine and .NET Desktop for the updater")
setup.add_argument("--dry-run", action="store_true")
setup.add_argument("--force-runtime", action="store_true")
update = subparsers.add_parser(
"update",
help="safely synchronize the official game depot without Wine",
)
update.add_argument("--dry-run", action="store_true")
update.add_argument("--repair", action="store_true", help="hash-check every game file")
import_options = update.add_mutually_exclusive_group()
import_options.add_argument(
"--seed-from",
type=Path,
help="import an existing game before updating",
)
import_options.add_argument(
"--no-import",
action="store_true",
help="disable automatic existing-game discovery",
)
sync = subparsers.add_parser(
"sync",
help="native official depot synchronization without bootstrap discovery",
)
sync.add_argument("--dry-run", action="store_true")
sync.add_argument("--repair", action="store_true", help="hash-check every game file")
legacy_update = subparsers.add_parser(
"wine-update",
help="legacy official WPF updater (normally unnecessary)",
)
legacy_update.add_argument("--dry-run", action="store_true")
legacy_update.add_argument("--force-runtime", action="store_true")
legacy_import_options = legacy_update.add_mutually_exclusive_group()
legacy_import_options.add_argument("--seed-from", type=Path)
legacy_import_options.add_argument("--no-import", action="store_true")
import_game = subparsers.add_parser(
"import-game",
help="safely seed the managed installation from an existing game",
)
import_game.add_argument("source", type=Path)
import_game.add_argument("--dry-run", action="store_true")
compatibility = subparsers.add_parser(
"cache-compatibility",
help="cache one exact verified Game.exe/game.dll compatibility pair",
)
compatibility.add_argument("source", type=Path)
runtime_patch = subparsers.add_parser(
"cache-runtime-patch",
help="cache the exact verified V21 texture-reclaim game.dll",
)
runtime_patch.add_argument("source", type=Path)
prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
prepare.add_argument("--game-dir", type=Path)
play = subparsers.add_parser("play", help="launch the game in the clean Gamescope box")
play.add_argument("--game-dir", type=Path)
play.add_argument("--no-box", action="store_true", help="launch without Gamescope")
play.add_argument("--dry-run", action="store_true")
play.add_argument("--skip-runtime-setup", action="store_true")
settings = subparsers.add_parser("settings", help="show or initialize settings")
settings.add_argument("--init", action="store_true", help="write conservative defaults")
launcher_update = subparsers.add_parser(
"launcher-update",
help="check, install, or roll back the native Linux launcher",
)
launcher_action = launcher_update.add_mutually_exclusive_group()
launcher_action.add_argument(
"--check",
action="store_true",
help="authenticate the update manifest without installing it",
)
launcher_action.add_argument(
"--rollback",
action="store_true",
help="switch back to the previously installed launcher release",
)
launcher_update.add_argument(
"--manifest-url",
help="override the signed stable-manifest URL",
)
launcher_update.add_argument(
"--public-key",
type=Path,
help="override the pinned launcher-update public key",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
arguments = parser.parse_args(argv)
command = arguments.command or "gui"
paths = AppPaths.from_environment()
try:
if command == "gui":
from .ui import run_dashboard
run_dashboard(paths)
return 0
if command == "doctor":
return _doctor(arguments.json)
if command == "fetch":
return _fetch(paths, arguments.dry_run, arguments.json)
if command == "setup":
return _setup(
paths,
arguments.dry_run,
arguments.force_runtime,
arguments.json,
)
if command == "update":
return _native_update(
paths,
arguments.dry_run,
arguments.repair,
arguments.seed_from,
arguments.no_import,
arguments.json,
)
if command == "sync":
return _native_update(
paths,
arguments.dry_run,
arguments.repair,
None,
True,
arguments.json,
)
if command == "wine-update":
return _wine_update(
paths,
arguments.dry_run,
arguments.force_runtime,
arguments.seed_from,
arguments.no_import,
arguments.json,
)
if command == "import-game":
return _import_game(
paths,
arguments.source,
arguments.dry_run,
arguments.json,
)
if command == "cache-compatibility":
result = cache_compatibility_binaries(paths, arguments.source)
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
return 0
if command == "cache-runtime-patch":
result = cache_runtime_patch(paths, arguments.source)
_emit({"ok": True, "runtime_patch": asdict(result)}, arguments.json)
return 0
if command == "prepare":
return _prepare(paths, arguments.game_dir, arguments.json)
if command == "play":
return _play(
paths,
arguments.game_dir,
arguments.no_box,
arguments.dry_run,
arguments.skip_runtime_setup,
arguments.json,
)
if command == "settings":
return _settings(paths, arguments.init, arguments.json)
if command == "launcher-update":
return _launcher_update(
paths,
arguments.check,
arguments.rollback,
arguments.manifest_url,
arguments.public_key,
arguments.json,
)
parser.error(f"unknown command: {command}")
except (ValueError, OSError, RuntimeError) as exc:
if arguments.json:
_print_json({"ok": False, "error": str(exc)})
else:
print(f"Error: {exc}", file=sys.stderr)
if isinstance(exc, CommandError):
if exc.outcome.stdout:
print(exc.outcome.stdout.rstrip(), file=sys.stderr)
if exc.outcome.stderr:
print(exc.outcome.stderr.rstrip(), file=sys.stderr)
return 1
def _doctor(as_json: bool) -> int:
report = inspect_host()
if as_json:
_print_json(report.to_dict())
else:
print(f"Distribution: {report.distribution} {report.version} ({report.architecture})")
print(f"Display: {report.display_backend}")
for tool in report.tools:
status = tool.path or "MISSING"
required = "required" if tool.required else "optional"
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
for warning in report.warnings:
print(f"Warning: {warning}")
if report.package_hint:
print(report.package_hint)
return 0 if report.ok else 1
def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int:
if not dry_run:
paths.create_base_directories()
if dry_run:
value = {
"ok": True,
"dry_run": True,
"url": OFFICIAL_LAUNCHER_URL,
"download": str(paths.launcher_pristine),
"prepared": str(paths.launcher_patched),
}
else:
download = fetch_official_launcher(
paths.launcher_pristine,
paths.launcher_metadata,
)
patch = prepare_launcher_binary(
paths.launcher_pristine,
paths.launcher_patched,
)
value = {
"ok": True,
"download": asdict(download),
"patch": asdict(patch),
"warning": None if patch.supported_profile else (
"The launcher is newer than the verified font-patch profile; "
"it was retained byte-for-byte."
),
}
_emit(value, as_json)
return 0
def _setup(
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
as_json: bool,
) -> int:
tools = find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = _runner(paths, "setup", dry_run)
with _operation_guard(paths, "setup", dry_run):
results = configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=force_runtime,
)
_emit(
{
"ok": True,
"dry_run": dry_run,
"prefix": str(paths.launcher_prefix),
"commands": [asdict(result) for result in results],
},
as_json,
)
return 0
def _native_update(
paths: AppPaths,
dry_run: bool,
repair: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
"""Use the native verified depot client for normal installs and updates."""
settings = _load_or_default_settings(paths, persist=not dry_run)
runner = _runner(paths, "native-update", dry_run)
with _operation_guard(paths, "native-update", dry_run):
_print_operation_status(as_json, 1, 3, "Checking local game files…")
game_import = None
if not no_import:
game_import = _maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
_print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
result = sync_managed_game(
paths,
repair=repair,
dry_run=dry_run,
progress=None if as_json else _print_sync_progress,
)
overlay = None
if not dry_run and paths.source_game.is_dir():
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
overlay = asdict(prepare_runtime_view(paths, paths.source_game))
_emit(
{
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"native_update": asdict(result),
"runtime_view": overlay,
},
as_json,
)
return 0
def _wine_update(
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
_fetch(paths, dry_run, True)
tools = find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = _runner(paths, "update", dry_run)
settings = _load_or_default_settings(paths, persist=not dry_run)
with _operation_guard(paths, "update", dry_run):
configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=force_runtime,
)
game_import = None
if not no_import:
game_import = _maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
updater = launch_official_updater(paths, tools, runner)
overlay = None
if not dry_run and paths.source_game.is_dir():
overlay = asdict(prepare_runtime_view(paths, paths.source_game))
value = {
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"updater": asdict(updater),
"runtime_view": overlay,
}
_emit(value, as_json)
return 0
def _print_sync_progress(progress: SyncProgress) -> None:
"""Emit line-delimited progress that the native Tk dashboard can consume."""
print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True)
def _print_operation_status(
as_json: bool,
step: int,
total: int,
message: str,
) -> None:
"""Emit a structured operation stage for the native launcher dashboard."""
if not as_json:
print(
f"STATUS {json.dumps({'step': step, 'total': total, 'message': message})}",
flush=True,
)
def _import_game(
paths: AppPaths,
source: Path,
dry_run: bool,
as_json: bool,
) -> int:
runner = _runner(paths, "import-game", dry_run)
if not as_json:
print(f"Importing existing game from {source.expanduser()}…", flush=True)
with _operation_guard(paths, "import-game", dry_run):
result = import_existing_game(paths, source, runner)
_emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json)
return 0
def _maybe_import_game(
paths: AppPaths,
settings: Settings,
seed_from: Path | None,
runner: CommandRunner,
*,
announce: bool,
) -> GameImportResult | None:
if paths.source_game.exists() or paths.source_game.is_symlink():
if seed_from is not None:
raise RuntimeError(
f"cannot seed an existing managed game directory: {paths.source_game}"
)
return None
candidate = seed_from
if candidate is None:
candidate = discover_existing_game(paths, settings)
if candidate is None:
return None
if announce:
print(f"Bootstrapping from existing game: {candidate}", flush=True)
return import_existing_game(paths, candidate, runner)
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
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)
return 0
def _play(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
) -> int:
settings = _load_or_default_settings(paths, persist=not dry_run)
source = _source_game(paths, settings, game_dir)
overlay = None
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
if not dry_run:
overlay = prepare_runtime_view(paths, source)
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run)
setup_results = []
if not skip_runtime_setup:
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
setup_results = prepare_game_runtime(paths, selection, runner)
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
outcome = run_game(
paths,
settings,
selection,
runner,
use_gamebox=not no_box,
)
_emit(
{
"ok": outcome.returncode == 0,
"dry_run": dry_run,
"runtime": asdict(selection),
"runtime_view": asdict(overlay) if overlay else None,
"setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome),
},
as_json,
)
return 0 if outcome.returncode == 0 else 1
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
if initialize:
save_settings(paths, Settings())
settings = ensure_settings(paths) if initialize else load_settings(paths)
_emit(
{
"ok": True,
"path": str(paths.settings_file),
"settings": asdict(settings),
},
as_json,
)
return 0
def _launcher_update(
paths: AppPaths,
check_only: bool,
rollback: bool,
manifest_url: str | None,
public_key: Path | None,
as_json: bool,
) -> int:
if rollback:
result = rollback_launcher_update(paths)
_emit(
{
"ok": True,
"launcher_rollback": asdict(result),
},
as_json,
)
return 0
if check_only:
check = check_for_launcher_update(
manifest_url=manifest_url,
public_key_path=public_key,
)
_emit(
{
"ok": True,
"launcher_update": update_check_to_dict(check),
},
as_json,
)
return 0
_print_operation_status(as_json, 1, 2, "Verifying the signed launcher release…")
result = install_launcher_update(
paths,
manifest_url=manifest_url,
public_key_path=public_key,
)
_print_operation_status(as_json, 2, 2, "Launcher update installed safely.")
_emit(
{
"ok": True,
"launcher_update": asdict(result),
},
as_json,
)
return 0
def _source_game(
paths: AppPaths,
settings: Settings,
argument: Path | None,
) -> Path:
if argument is not None:
return argument.expanduser().resolve()
source_override = settings.source_override
if source_override is not None:
return source_override
return paths.source_game
def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
if paths.settings_file.is_file():
return load_settings(paths)
if persist:
return ensure_settings(paths)
return Settings()
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
def _operation_guard(
paths: AppPaths,
operation: str,
dry_run: bool,
) -> AbstractContextManager[None]:
if dry_run:
return nullcontext()
return launcher_operation_lock(paths.launcher_operation_lock, operation)
def _emit(value: object, as_json: bool) -> None:
if as_json:
_print_json(value)
else:
if isinstance(value, dict) and value.get("ok") is True:
print("Done.")
print(json.dumps(value, indent=2, sort_keys=True))
def _print_json(value: object) -> None:
print(json.dumps(value, indent=2, sort_keys=True))

212
ptlinux/command.py Normal file
View File

@@ -0,0 +1,212 @@
"""Inspectable subprocess execution for installer operations."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import time
from typing import Mapping, Sequence, TextIO
@dataclass(frozen=True)
class CommandOutcome:
argv: list[str]
returncode: int | None
stdout: str
stderr: str
duration_seconds: float
timed_out: bool
dry_run: bool
class CommandError(RuntimeError):
"""Raised when a required installer command fails."""
def __init__(self, outcome: CommandOutcome) -> None:
self.outcome = outcome
returncode = "unknown" if outcome.returncode is None else str(outcome.returncode)
message = f"command failed ({returncode}): {outcome.argv!r}"
if outcome.timed_out:
message = f"command timed out: {outcome.argv!r}"
super().__init__(message)
class CommandRunner:
"""Run argument arrays with timeouts and append-only JSONL logging."""
def __init__(self, log_path: Path, *, dry_run: bool = False) -> None:
self.log_path = log_path
self.dry_run = dry_run
def run(
self,
argv: Sequence[str],
*,
cwd: Path | None = None,
env_overrides: Mapping[str, str] | None = None,
timeout_seconds: float = 300.0,
check: bool = True,
stream_output: bool = False,
) -> CommandOutcome:
arguments = [str(value) for value in argv]
if not arguments:
raise ValueError("command argv cannot be empty")
overrides = dict(env_overrides or {})
started = time.monotonic()
if self.dry_run:
outcome = CommandOutcome(
argv=arguments,
returncode=0,
stdout="",
stderr="",
duration_seconds=0.0,
timed_out=False,
dry_run=True,
)
self._record(outcome, cwd, overrides)
return outcome
environment = os.environ.copy()
environment.update(overrides)
if stream_output:
outcome = self._run_streamed(
arguments,
cwd=cwd,
environment=environment,
timeout_seconds=timeout_seconds,
started=started,
)
else:
try:
completed = subprocess.run(
arguments,
cwd=str(cwd) if cwd is not None else None,
env=environment,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
outcome = CommandOutcome(
argv=arguments,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
duration_seconds=time.monotonic() - started,
timed_out=False,
dry_run=False,
)
except subprocess.TimeoutExpired as exc:
outcome = CommandOutcome(
argv=arguments,
returncode=None,
stdout=_as_text(exc.stdout),
stderr=_as_text(exc.stderr),
duration_seconds=time.monotonic() - started,
timed_out=True,
dry_run=False,
)
self._record(outcome, cwd, overrides)
if check and (outcome.timed_out or outcome.returncode != 0):
raise CommandError(outcome)
return outcome
@staticmethod
def _run_streamed(
arguments: list[str],
*,
cwd: Path | None,
environment: Mapping[str, str],
timeout_seconds: float,
started: float,
) -> CommandOutcome:
process = subprocess.Popen(
arguments,
cwd=str(cwd) if cwd is not None else None,
env=dict(environment),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
)
stdout: list[str] = []
stderr: list[str] = []
def relay(
stream: TextIO,
capture: list[str],
destination: TextIO,
) -> None:
while True:
line = stream.readline()
if not line:
return
capture.append(line)
destination.write(line)
destination.flush()
assert process.stdout is not None
assert process.stderr is not None
stdout_thread = threading.Thread(
target=relay,
args=(process.stdout, stdout, sys.stdout),
daemon=True,
)
stderr_thread = threading.Thread(
target=relay,
args=(process.stderr, stderr, sys.stderr),
daemon=True,
)
stdout_thread.start()
stderr_thread.start()
timed_out = False
try:
returncode = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
timed_out = True
process.kill()
returncode = process.wait()
stdout_thread.join()
stderr_thread.join()
process.stdout.close()
process.stderr.close()
return CommandOutcome(
argv=arguments,
returncode=None if timed_out else returncode,
stdout="".join(stdout),
stderr="".join(stderr),
duration_seconds=time.monotonic() - started,
timed_out=timed_out,
dry_run=False,
)
def _record(
self,
outcome: CommandOutcome,
cwd: Path | None,
env_overrides: Mapping[str, str],
) -> None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().astimezone().isoformat(),
"cwd": str(cwd) if cwd is not None else None,
"env_overrides": dict(sorted(env_overrides.items())),
**asdict(outcome),
}
with self.log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, sort_keys=True) + "\n")
def _as_text(value: bytes | str | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value

1091
ptlinux/depot.py Normal file

File diff suppressed because it is too large Load Diff

108
ptlinux/desktop_entry.py Normal file
View File

@@ -0,0 +1,108 @@
"""Install a desktop entry that does not depend on the GUI session's PATH."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
from typing import Sequence
EXECUTABLE_TOKEN = "@PTLINUX_EXECUTABLE@"
ICON_TOKEN = "@PTLINUX_ICON@"
class DesktopEntryError(ValueError):
"""Raised when a safe desktop entry cannot be rendered."""
def quote_exec_argument(value: str) -> str:
"""Quote one Desktop Entry ``Exec`` argument without field-code expansion."""
if not value or any(character in value for character in ("\x00", "\n", "\r")):
raise DesktopEntryError("desktop executable path contains invalid characters")
escaped = value.replace("\\", "\\\\\\\\")
escaped = escaped.replace('"', '\\\\"')
escaped = escaped.replace("`", "\\\\`")
escaped = escaped.replace("$", "\\\\$")
escaped = escaped.replace("%", "%%")
return f'"{escaped}"'
def escape_icon_path(value: str) -> str:
"""Escape one absolute Desktop Entry ``Icon`` path as a string value."""
if not value or any(character in value for character in ("\x00", "\n", "\r")):
raise DesktopEntryError("desktop icon path contains invalid characters")
return value.replace("\\", "\\\\")
def render_desktop_entry(template: str, executable: Path, icon: Path) -> str:
"""Replace launcher and icon tokens with validated absolute paths."""
if template.count(EXECUTABLE_TOKEN) != 1:
raise DesktopEntryError("desktop template must contain exactly one executable token")
if template.count(ICON_TOKEN) != 1:
raise DesktopEntryError("desktop template must contain exactly one icon token")
launcher = executable.expanduser()
if not launcher.is_absolute():
raise DesktopEntryError("desktop launcher path must be absolute")
if not launcher.is_file() or not os.access(launcher, os.X_OK):
raise DesktopEntryError(f"desktop launcher is missing or not executable: {launcher}")
icon_path = icon.expanduser()
if not icon_path.is_absolute():
raise DesktopEntryError("desktop icon path must be absolute")
if not icon_path.is_file():
raise DesktopEntryError(f"desktop icon is missing: {icon_path}")
rendered = template.replace(EXECUTABLE_TOKEN, quote_exec_argument(str(launcher)))
rendered = rendered.replace(ICON_TOKEN, escape_icon_path(str(icon_path)))
return rendered if rendered.endswith("\n") else rendered + "\n"
def install_desktop_entry(
template: Path,
executable: Path,
icon: Path,
destination: Path,
) -> None:
"""Render and atomically install one user-level desktop entry."""
rendered = render_desktop_entry(
template.read_text(encoding="utf-8"),
executable,
icon,
)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
try:
temporary.write_text(rendered, encoding="utf-8")
temporary.chmod(0o644)
temporary.replace(destination)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="install the PristonTale EU desktop entry")
parser.add_argument("template", type=Path)
parser.add_argument("executable", type=Path)
parser.add_argument("icon", type=Path)
parser.add_argument("destination", type=Path)
arguments = parser.parse_args(argv)
try:
install_desktop_entry(
arguments.template,
arguments.executable,
arguments.icon,
arguments.destination,
)
except (DesktopEntryError, OSError) as exc:
print(f"Could not install desktop shortcut: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

184
ptlinux/doctor.py Normal file
View File

@@ -0,0 +1,184 @@
"""Cross-distribution dependency and host diagnostics."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from importlib.util import find_spec
import os
from pathlib import Path
import platform
import shutil
from typing import Mapping
from .launcher import find_wine_tools
@dataclass(frozen=True)
class ToolStatus:
name: str
path: str | None
required: bool
purpose: str
@dataclass(frozen=True)
class DoctorReport:
ok: bool
distribution: str
version: str
architecture: str
display_backend: str
tools: list[ToolStatus]
package_hint: str | None
warnings: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
env = os.environ if environment is None else environment
os_release = _read_os_release()
distribution = os_release.get("ID", "unknown")
version = os_release.get("VERSION_ID", "unknown")
wine_tools = find_wine_tools()
tools = [
ToolStatus(
"python3",
shutil.which("python3"),
True,
"native installer and command-line interface",
),
ToolStatus(
"python-tk",
_python_tk_path(),
True,
"native dashboard",
),
ToolStatus(
"wine",
str(wine_tools.wine) if wine_tools else None,
True,
"legacy updater support and game compatibility utilities",
),
ToolStatus(
"winetricks",
str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"),
True,
"native D3D helper DLLs for the Proton game prefix",
),
ToolStatus(
"gamescope",
shutil.which("gamescope"),
True,
"borderless X11/Wayland game container",
),
ToolStatus(
"cabextract",
shutil.which("cabextract"),
True,
"Microsoft runtime extraction through Winetricks",
),
ToolStatus(
"openssl",
shutil.which("openssl"),
True,
"signed native-launcher update verification",
),
ToolStatus(
"umu-run",
shutil.which("umu-run"),
False,
"portable Proton runtime when Steam Proton 7 is unavailable",
),
ToolStatus(
"vulkaninfo",
shutil.which("vulkaninfo"),
False,
"Vulkan diagnostics",
),
]
warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
warnings.append("No graphical X11 or Wayland session is visible.")
if shutil.which("umu-run") is None and not _has_steam_proton7(env):
warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
)
required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
distribution=distribution,
version=version,
architecture=platform.machine(),
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
tools=tools,
package_hint=_package_hint(distribution),
warnings=warnings,
)
def _read_os_release() -> dict[str, str]:
result: dict[str, str] = {}
try:
lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines()
except OSError:
return result
for line in lines:
if "=" not in line or line.startswith("#"):
continue
key, value = line.split("=", 1)
result[key] = value.strip().strip('"')
return result
def _python_tk_path() -> str | None:
try:
specification = find_spec("_tkinter")
except (ImportError, ValueError):
return None
if specification is None:
return None
return specification.origin or "available"
def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return (
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, Gamescope, "
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
)
if distribution in {"fedora", "nobara"}:
return (
"Install python3-tkinter, wine, winetricks, gamescope, cabextract, openssl, "
"and the matching 32-bit Vulkan driver with DNF."
)
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
return (
"Install python, tk, wine, winetricks, gamescope, cabextract, openssl, and "
"the matching lib32 Vulkan driver with Pacman."
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return (
"Install Python/Tk, Wine, Winetricks, Gamescope, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper."
)
return None
def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
home_value = environment.get("HOME")
if not home_value:
return False
home = Path(home_value)
for steam_root in (
home / ".local/share/Steam",
home / ".steam/steam",
home / "snap/steam/common/.local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
):
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
return True
return False

210
ptlinux/download.py Normal file
View File

@@ -0,0 +1,210 @@
"""Secure, conditional download of the official PristonTale EU launcher."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import struct
from typing import Callable
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from . import __version__
OFFICIAL_LAUNCHER_URL = (
"https://pristontale.eu/storage/uploads/Pristontale%20EU%20Launcher.exe"
)
MAX_LAUNCHER_BYTES = 64 * 1024 * 1024
READ_SIZE = 256 * 1024
USER_AGENT = f"PT-Linux/{__version__} (+https://pristontale.eu/)"
class DownloadError(RuntimeError):
"""Raised when the official launcher cannot be safely downloaded."""
@dataclass(frozen=True)
class LauncherDownload:
path: str
url: str
sha256: str
size: int
etag: str | None
last_modified: str | None
fetched_at: str
changed: bool
def fetch_official_launcher(
destination: Path,
metadata_path: Path,
*,
url: str = OFFICIAL_LAUNCHER_URL,
timeout_seconds: float = 60.0,
progress: Callable[[int, int | None], None] | None = None,
) -> LauncherDownload:
"""Download the current official launcher and atomically replace the cache."""
metadata = _read_metadata(metadata_path)
headers = {"User-Agent": USER_AGENT}
if destination.is_file() and _looks_like_pe(destination):
etag = metadata.get("etag")
last_modified = metadata.get("last_modified")
if isinstance(etag, str) and etag:
headers["If-None-Match"] = etag
if isinstance(last_modified, str) and last_modified:
headers["If-Modified-Since"] = last_modified
request = Request(url, headers=headers, method="GET")
try:
response = urlopen(request, timeout=timeout_seconds)
except HTTPError as exc:
if exc.code == 304 and destination.is_file():
return _existing_download(destination, metadata, url)
raise DownloadError(f"official launcher request failed: HTTP {exc.code}") from exc
except OSError as exc:
raise DownloadError(f"official launcher request failed: {exc}") from exc
with response:
length = _content_length(response.headers.get("Content-Length"))
if length is not None and length > MAX_LAUNCHER_BYTES:
raise DownloadError(
f"launcher response is unexpectedly large: {length} bytes"
)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part")
digest = hashlib.sha256()
written = 0
try:
with temporary.open("wb") as handle:
while True:
chunk = response.read(READ_SIZE)
if not chunk:
break
written += len(chunk)
if written > MAX_LAUNCHER_BYTES:
raise DownloadError(
"launcher response exceeded the 64 MiB safety limit"
)
digest.update(chunk)
handle.write(chunk)
if progress is not None:
progress(written, length)
handle.flush()
os.fsync(handle.fileno())
if length is not None and written != length:
raise DownloadError(
f"incomplete launcher response: {written}/{length} bytes"
)
validate_pe_launcher(temporary)
temporary.replace(destination)
except BaseException:
temporary.unlink(missing_ok=True)
raise
result = LauncherDownload(
path=str(destination),
url=url,
sha256=digest.hexdigest(),
size=written,
etag=response.headers.get("ETag"),
last_modified=response.headers.get("Last-Modified"),
fetched_at=datetime.now().astimezone().isoformat(),
changed=True,
)
_write_json(metadata_path, asdict(result))
return result
def validate_pe_launcher(path: Path) -> None:
"""Validate the minimum PE structure and require a 64-bit GUI executable."""
try:
with path.open("rb") as handle:
dos = handle.read(64)
if len(dos) < 64 or dos[:2] != b"MZ":
raise DownloadError("download is not a DOS/PE executable")
pe_offset = struct.unpack_from("<I", dos, 0x3C)[0]
if pe_offset < 64 or pe_offset > 8 * 1024 * 1024:
raise DownloadError("launcher has an invalid PE header offset")
handle.seek(pe_offset)
pe_header = handle.read(24)
if len(pe_header) != 24 or pe_header[:4] != b"PE\x00\x00":
raise DownloadError("launcher has no valid PE signature")
machine = struct.unpack_from("<H", pe_header, 4)[0]
if machine != 0x8664:
raise DownloadError(
f"launcher has unexpected PE machine type 0x{machine:04x}"
)
except OSError as exc:
raise DownloadError(f"cannot validate launcher: {exc}") from exc
def _looks_like_pe(path: Path) -> bool:
try:
validate_pe_launcher(path)
except DownloadError:
return False
return True
def _existing_download(
path: Path,
metadata: dict[str, object],
url: str,
) -> LauncherDownload:
validate_pe_launcher(path)
return LauncherDownload(
path=str(path),
url=url,
sha256=_sha256(path),
size=path.stat().st_size,
etag=_optional_string(metadata.get("etag")),
last_modified=_optional_string(metadata.get("last_modified")),
fetched_at=datetime.now().astimezone().isoformat(),
changed=False,
)
def _read_metadata(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
def _content_length(value: str | None) -> int | None:
if value is None:
return None
try:
length = int(value)
except ValueError as exc:
raise DownloadError(f"invalid Content-Length: {value!r}") from exc
if length < 0:
raise DownloadError(f"invalid Content-Length: {value!r}")
return length
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) else None

27
ptlinux/gamebox.py Normal file
View File

@@ -0,0 +1,27 @@
"""Native X11 command construction for the clean borderless game window."""
from __future__ import annotations
import sys
from typing import Sequence
from .settings import Settings
def build_gamebox_command(
game_command: Sequence[str],
settings: Settings,
) -> list[str]:
if not game_command:
raise ValueError("game command cannot be empty")
command = [
sys.executable,
"-m",
"ptlinux.gamebox_runtime",
"--window-title",
"PristonTale EU",
]
if settings.borderless:
command.append("--borderless")
command.extend(("--", *[str(value) for value in game_command]))
return command

315
ptlinux/gamebox_runtime.py Normal file
View File

@@ -0,0 +1,315 @@
"""Map and style the real Wine game window on the host X11 display."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import subprocess
import sys
import time
from collections.abc import Collection, Iterable, Sequence
WINDOW_TITLE = "PristonTale EU"
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
def _ignore_x11_error(_display: object, _event: object) -> int:
"""Keep a stale X11 window race from terminating the mapper process."""
return 0
def _matches_game_window_title(title: str, expected_title: str) -> bool:
"""Match the game title without accepting dialogs that merely mention it."""
candidate = title.strip().casefold()
expected = expected_title.strip().casefold()
if not candidate or not expected:
return False
return candidate == expected or candidate.startswith(f"{expected} ")
def _select_game_window(
windows: Iterable[tuple[int, str]],
expected_title: str,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
for window, title in windows:
if window in excluded_window_ids:
continue
if _matches_game_window_title(title, expected_title):
return window, title
return None
class GameboxRuntimeError(RuntimeError):
"""Raised when the game command or X11 mapper cannot be started."""
class _MotifWmHints(ctypes.Structure):
_fields_ = [
("flags", ctypes.c_ulong),
("functions", ctypes.c_ulong),
("decorations", ctypes.c_ulong),
("input_mode", ctypes.c_long),
("status", ctypes.c_ulong),
]
class _X11:
def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
try:
self.library = ctypes.CDLL(library_path)
except OSError as exc:
raise GameboxRuntimeError("the X11 client library is unavailable") from exc
self._configure_signatures()
self._error_handler = _X_ERROR_HANDLER(_ignore_x11_error) # type: ignore[arg-type]
self._previous_error_handler = self.library.XSetErrorHandler(
ctypes.cast(self._error_handler, ctypes.c_void_p),
)
self.display = self.library.XOpenDisplay(None)
if not self.display:
self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display")
def close(self) -> None:
try:
if self.display:
self.library.XCloseDisplay(self.display)
self.display = None
finally:
self._restore_error_handler()
def root_window_ids(self) -> set[int]:
return set(self._root_windows())
def find_first_window(
self,
title_fragment: str,
*,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
windows: list[tuple[int, str]] = []
for window in self._root_windows():
title = self._window_title(window)
if title is not None:
windows.append((window, title))
return _select_game_window(windows, title_fragment, excluded_window_ids)
def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display)
root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)()
child_count = ctypes.c_uint()
status = self.library.XQueryTree(
self.display,
root,
ctypes.byref(root_return),
ctypes.byref(parent_return),
ctypes.byref(children),
ctypes.byref(child_count),
)
if status == 0:
return []
try:
return [int(children[index]) for index in range(child_count.value)]
finally:
if children:
self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
def map_window(self, window: int, *, borderless: bool) -> None:
if borderless:
self._remove_decorations(window)
self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display)
def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
if atom == 0:
return
hints = _MotifWmHints(
flags=1 << 1,
functions=0,
decorations=0,
input_mode=0,
status=0,
)
self.library.XChangeProperty(
self.display,
window,
atom,
atom,
32,
0,
ctypes.cast(ctypes.byref(hints), ctypes.POINTER(ctypes.c_ubyte)),
5,
)
def _window_title(self, window: int) -> str | None:
value = ctypes.c_char_p()
if self.library.XFetchName(self.display, window, ctypes.byref(value)) == 0:
return None
try:
if value.value is None:
return None
return value.value.decode("utf-8", errors="replace")
finally:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def _restore_error_handler(self) -> None:
if self._error_handler is None:
return
self.library.XSetErrorHandler(self._previous_error_handler)
self._error_handler = None
def _configure_signatures(self) -> None:
self.library.XSetErrorHandler.argtypes = [ctypes.c_void_p]
self.library.XSetErrorHandler.restype = ctypes.c_void_p
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.library.XOpenDisplay.restype = ctypes.c_void_p
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.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),
]
self.library.XQueryTree.restype = ctypes.c_int
self.library.XFetchName.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_char_p),
]
self.library.XFetchName.restype = ctypes.c_int
self.library.XInternAtom.argtypes = [
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_int,
]
self.library.XInternAtom.restype = ctypes.c_ulong
self.library.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,
]
self.library.XChangeProperty.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p]
self.library.XFlush.restype = ctypes.c_int
self.library.XFree.argtypes = [ctypes.c_void_p]
self.library.XFree.restype = ctypes.c_int
self.library.XCloseDisplay.argtypes = [ctypes.c_void_p]
self.library.XCloseDisplay.restype = ctypes.c_int
def run_game_window(
command: Sequence[str],
window_title: str = WINDOW_TITLE,
*,
borderless: bool = False,
) -> int:
if not command:
raise GameboxRuntimeError("game command cannot be empty")
mapper: _X11 | None = None
existing_window_ids: set[int] = set()
try:
created_mapper = _X11()
existing_window_ids = created_mapper.root_window_ids()
mapper = created_mapper
except GameboxRuntimeError as error:
print(
f"Warning: {error}; continuing without X11 window styling",
file=sys.stderr,
flush=True,
)
process = subprocess.Popen([str(value) for value in command])
if mapper is None:
print(
f"Game window ready: unmanaged {window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
game_window: int | None = None
game_title: str | None = None
game_window_created_at: float | None = None
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
try:
while process.poll() is None and time.monotonic() < deadline:
now = time.monotonic()
if game_window is None:
found = mapper.find_first_window(
window_title,
excluded_window_ids=existing_window_ids,
)
if found is not None:
window, title = found
game_window = window
game_title = title
game_window_created_at = now
print(
f"Game window created; preparing it: {title}",
file=sys.stderr,
flush=True,
)
if (
game_window is not None
and game_window_created_at is not None
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
):
mapper.map_window(game_window, borderless=borderless)
print(
f"Game window ready: {game_title or window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
time.sleep(0.1)
if process.poll() is None:
print(
f"Warning: no new game window matching {window_title!r} appeared",
file=sys.stderr,
flush=True,
)
if game_window is not None:
mapper.map_window(game_window, borderless=borderless)
return int(process.wait())
finally:
mapper.close()
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--window-title", default=WINDOW_TITLE)
parser.add_argument("--borderless", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv)
command = arguments.command
if command and command[0] == "--":
command = command[1:]
try:
return run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
)
except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

332
ptlinux/importer.py Normal file
View File

@@ -0,0 +1,332 @@
"""Safe local bootstrap from an existing PristonTale EU installation."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
from typing import Mapping
import uuid
from .command import CommandRunner
from .install_status import REQUIRED_GAME_FILES
from .overlay import COMPATIBILITY_BINARY_HASHES, cache_compatibility_binaries
from .paths import AppPaths
from .settings import Settings
class GameImportError(RuntimeError):
"""Raised when a source cannot be safely imported."""
@dataclass(frozen=True)
class GameTreeInventory:
files: int
bytes: int
core_sha256: dict[str, str]
@dataclass(frozen=True)
class GameImportResult:
source: str
destination: str
copy_mode: str
files: int
bytes: int
core_sha256: dict[str, str]
depot_metadata_source: str | None
depot_metadata_destination: str | None
installed_build: int | None
started_at: str
completed_at: str
def import_existing_game(
paths: AppPaths,
source_game: Path,
runner: CommandRunner,
*,
environment: Mapping[str, str] | None = None,
) -> GameImportResult:
"""Clone a complete source without ever writing through to it."""
source = source_game.expanduser().resolve(strict=True)
destination = paths.source_game
_validate_destination(paths, destination)
if source == destination.resolve(strict=False):
raise GameImportError("source and managed destination are the same directory")
if destination.resolve(strict=False).is_relative_to(source):
raise GameImportError("managed destination cannot be inside the source game tree")
if destination.exists() or destination.is_symlink():
raise GameImportError(
f"managed game directory already exists: {destination}; "
"finish or remove the existing updater installation first"
)
started_at = datetime.now().astimezone().isoformat()
source_before = inventory_game_tree(source)
depot_metadata = discover_depot_metadata(source)
if runner.dry_run:
return GameImportResult(
source=str(source),
destination=str(destination),
copy_mode="reflink-or-copy-dry-run",
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=started_at,
)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
try:
temporary.mkdir(parents=False, exist_ok=False)
runner.run(
["cp", "-a", "--reflink=auto", "--", f"{source}/.", str(temporary)],
timeout_seconds=1800.0,
)
source_after = inventory_game_tree(source)
copied = inventory_game_tree(temporary)
if source_after != source_before:
raise GameImportError("source game changed during import; staged copy discarded")
if copied != source_before:
raise GameImportError(
"staged game inventory does not match the source; staged copy discarded"
)
if all(
source_before.core_sha256.get(name) == expected
for name, expected in COMPATIBILITY_BINARY_HASHES.items()
):
cache_compatibility_binaries(paths, source)
temporary.replace(destination)
except BaseException:
_remove_staging_tree(temporary, destination.parent)
raise
metadata_destination = None
if depot_metadata is not None:
metadata_destination = _install_depot_metadata(
paths,
depot_metadata,
environment,
)
result = 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=(
str(metadata_destination) if metadata_destination is not None else 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(),
)
_write_json(paths.game_import_manifest, asdict(result))
return result
@dataclass(frozen=True)
class DepotMetadata:
path: Path
installed_build: int
sha256: str
def discover_depot_metadata(source_game: Path) -> DepotMetadata | None:
source = source_game.expanduser().resolve(strict=True)
prefix = None
for parent in source.parents:
if parent.name.casefold() == "drive_c":
prefix = parent.parent
break
if prefix is None:
return None
users = prefix / "drive_c/users"
try:
user_directories = sorted(path for path in users.iterdir() if path.is_dir())
except OSError:
return None
for user_directory in user_directories:
candidate = (
user_directory
/ "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
)
try:
value = json.loads(candidate.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(value, dict):
continue
installed_build = value.get("InstalledBuild")
if (
isinstance(installed_build, bool)
or not isinstance(installed_build, int)
or installed_build <= 0
or value.get("UpdateComplete") is not True
):
continue
install_folder = value.get("InstallFolder")
if not isinstance(install_folder, str) or not install_folder.casefold().endswith(
r"\program files (x86)\pristontale eu".casefold()
):
continue
return DepotMetadata(
path=candidate,
installed_build=installed_build,
sha256=_sha256(candidate),
)
return None
def inventory_game_tree(source_game: Path) -> GameTreeInventory:
source = source_game.expanduser().resolve(strict=True)
for relative in REQUIRED_GAME_FILES:
path = source / relative
if not path.is_file():
raise GameImportError(f"game installation is incomplete: missing {relative}")
files = 0
total_bytes = 0
for root, _directories, filenames in os.walk(source, followlinks=False):
root_path = Path(root)
for filename in filenames:
path = root_path / filename
if path.is_symlink():
files += 1
continue
try:
total_bytes += path.stat().st_size
except OSError as exc:
raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc
files += 1
return GameTreeInventory(
files=files,
bytes=total_bytes,
core_sha256={str(relative): _sha256(source / relative) for relative in REQUIRED_GAME_FILES},
)
def discover_existing_game(
paths: AppPaths,
settings: Settings,
environment: Mapping[str, str] | None = None,
) -> Path | None:
env = os.environ if environment is None else environment
candidates: list[Path] = []
source_override = settings.source_override
if source_override is not None:
candidates.append(source_override)
home_value = env.get("HOME")
if home_value:
home = Path(home_value).expanduser()
for root_name in ("PristonTale-EU", "pristontale-eu"):
root = home / "Games" / root_name
candidates.extend(
(
root / "wine/drive_c/Program Files (x86)/Pristontale EU",
root / "drive_c/Program Files (x86)/Pristontale EU",
root / "Pristontale EU",
)
)
destination = paths.source_game.resolve(strict=False)
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.expanduser().resolve(strict=False)
if resolved in seen or resolved == destination:
continue
seen.add(resolved)
try:
inventory_game_tree(resolved)
except (FileNotFoundError, GameImportError, OSError):
continue
return resolved
return None
def _validate_destination(paths: AppPaths, destination: Path) -> None:
expected = (
paths.launcher_prefix
/ "drive_c/Program Files (x86)/Pristontale EU"
).resolve(strict=False)
if destination.resolve(strict=False) != expected:
raise GameImportError(f"unexpected managed game destination: {destination}")
def _install_depot_metadata(
paths: AppPaths,
metadata: DepotMetadata,
environment: Mapping[str, str] | None,
) -> Path:
env = os.environ if environment is None else environment
users_root = paths.launcher_prefix / "drive_c/users"
preferred_names = [env.get("USER"), Path(env.get("HOME", "")).name]
user_home = None
for name in preferred_names:
if not name:
continue
candidate = users_root / name
if candidate.is_dir():
user_home = candidate
break
if user_home is None:
name = next((value for value in preferred_names if value), None)
if name is None:
raise GameImportError("cannot determine the Wine user for depot metadata")
user_home = users_root / name
target = user_home / "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
try:
shutil.copy2(metadata.path, temporary)
if _sha256(temporary) != metadata.sha256:
raise GameImportError("copied depot metadata hash mismatch")
temporary.replace(target)
except BaseException:
temporary.unlink(missing_ok=True)
raise
return target
def _remove_staging_tree(path: Path, expected_parent: Path) -> None:
if path.parent.resolve() != expected_parent.resolve():
raise GameImportError(f"refusing to remove staging path outside destination: {path}")
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path)
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 _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)

92
ptlinux/install_status.py Normal file
View File

@@ -0,0 +1,92 @@
"""Read-only installation state used by the native launcher."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
from pathlib import Path
from .paths import AppPaths
REQUIRED_GAME_FILES = (
Path("Game.exe"),
Path("game.dll"),
Path("game.ini"),
Path("game/maps/WhisperingVale.rovug"),
)
class PrimaryAction(str, Enum):
"""Action presented by the launcher's large primary button."""
INSTALL = "INSTALL"
UPDATE = "UPDATE"
PLAY = "PLAY"
@dataclass(frozen=True)
class GameInstallationStatus:
"""Local game completeness and its relationship to the official depot."""
action: PrimaryAction
installed: bool
local_build: int | None
official_build: int | None
missing_files: tuple[Path, ...]
def inspect_game_installation(
paths: AppPaths,
official_build: int | None = None,
) -> GameInstallationStatus:
"""Select Install, Update, or Play without modifying the installation.
A completed native sync is the verified source for the local depot build.
When the official build cannot be checked, a complete local game remains
playable so temporary network failures do not lock users out.
"""
if official_build is not None and (
isinstance(official_build, bool)
or not isinstance(official_build, int)
or official_build < 0
):
raise ValueError("official build must be a non-negative integer")
missing_files = tuple(
relative
for relative in REQUIRED_GAME_FILES
if not (paths.source_game / relative).is_file()
)
installed = not missing_files
local_build = _read_verified_local_build(paths.native_depot_state)
if not installed:
action = PrimaryAction.INSTALL
elif official_build is not None and (
local_build is None or local_build < official_build
):
action = PrimaryAction.UPDATE
else:
action = PrimaryAction.PLAY
return GameInstallationStatus(
action=action,
installed=installed,
local_build=local_build,
official_build=official_build,
missing_files=missing_files,
)
def _read_verified_local_build(path: Path) -> int | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict) or value.get("dry_run") is not False:
return None
build = value.get("build")
if isinstance(build, bool) or not isinstance(build, int) or build < 0:
return None
return build

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt7MwE0ZLWR6uy0aEW2b/
Hr/txx9mSuWxKUxQAE07Gl7A5ZTgivhZ9w0pbCcVtcirhg9lzi7OKcc3VrERcHTq
lK1+h3NENPNSVfhQeTjIEIC9W8hXK1/mHnz1IEk5nziGarnGTiSaIaI23TXZ5QtY
vbpj+gTkSZswsLUlBCENFaS/1k6YZLGJdJ9l4luTkV47vqD6lvPYY8Mob3AxCwTj
PuEZYMW53NfZx/Lrk7MVzTrTa66L9cFnLUGHwguiA9SbjzppDOw6YJw3sTZalC+a
xseEle0dymCjCyEU1fVCizvW88/ZV+a2kpvfMWUVBl4MAzyH8Ja+FFqAkzjd2YKx
7fI1X5Cr9rTG8obmYfH/Co+gfiU2jDeQZL6ELB9/zukWYxJp1jztlPxB4/nHoJgG
1QCM7i0QGne4kIgWn6Qm2uBu/ucwaDlm/842VHKQEtcKBeYpkxO9B5xcRBJ59qMu
OSUuFAEiX4TN56wS7F1XTogpFTUJs5PX42d9Rd6hkW3ZAgMBAAE=
-----END PUBLIC KEY-----

322
ptlinux/launcher.py Normal file
View File

@@ -0,0 +1,322 @@
"""Wine launcher preparation and exact-profile WPF compatibility patching."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
from typing import Iterable
from .command import CommandOutcome, CommandRunner
from .paths import AppPaths
KNOWN_PRISTINE_LAUNCHER_SHA256 = (
"64e46469d7de675e52741cabf71a8c4b7852769b96bd59ef9a15032b3d61c4d7"
)
KNOWN_PATCHED_LAUNCHER_SHA256 = (
"aa18a8d74b1c5a644251babe15ae6828a221bdb65f970cdef275276ca750f1e8"
)
KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256 = (
"fb89f2e8a7f68775c4c1a09ea7f88f1799b8f36355985d26b30f56701d11f677"
)
LAUNCHER_PREFIX_CONFIGURATION_VERSION = 1
_NEWS_FEED_FONT_OFFSET = 269118
_PATCHES = (
(b"/Resources/Fonts/#FontAwesome", b"FontAwesome,Lato,Arial,Tahoma", 1),
(b"/Resources/Fonts/#Lato Regular", b"Lato Regular,Lato,Arial,Tahoma", 1),
)
class LauncherError(RuntimeError):
"""Raised when the launcher cannot be safely prepared."""
@dataclass(frozen=True)
class WineTools:
wine: Path
wineboot: Path
winetricks: Path
@dataclass(frozen=True)
class LauncherPatchResult:
source: str
destination: str
source_sha256: str
destination_sha256: str
patched: bool
supported_profile: bool
replacements: dict[str, int]
def find_wine_tools() -> WineTools | None:
wine_candidates = (
Path("/opt/wine-stable/bin/wine"),
Path("/opt/wine-staging/bin/wine"),
Path("/opt/wine/bin/wine"),
Path("/usr/local/bin/wine"),
Path("/usr/bin/wine"),
Path("/usr/bin/wine64"),
)
wine = next((path for path in wine_candidates if path.is_file()), None)
if wine is None:
located = shutil.which("wine") or shutil.which("wine64")
wine = Path(located) if located else None
if wine is None:
return None
wineboot = wine.with_name("wineboot")
if not wineboot.is_file():
located_boot = shutil.which("wineboot")
if not located_boot:
return None
wineboot = Path(located_boot)
located_winetricks = shutil.which("winetricks")
if not located_winetricks:
return None
return WineTools(
wine=wine.resolve(),
# Preserve the wineboot basename when it is a dispatcher symlink to Wine.
wineboot=wineboot.absolute(),
winetricks=Path(located_winetricks).resolve(),
)
def prepare_launcher_binary(source: Path, destination: Path) -> LauncherPatchResult:
"""Patch only the exact known launcher profile; retain unknown builds intact."""
source_hash = _sha256(source)
data = source.read_bytes()
replacements: dict[str, int] = {}
supported = source_hash == KNOWN_PRISTINE_LAUNCHER_SHA256
if supported:
for old, new, expected_count in _PATCHES:
if len(old) != len(new):
raise AssertionError("launcher replacement lengths must match")
actual_count = data.count(old)
if actual_count != expected_count:
raise LauncherError(
f"known launcher profile changed for {old!r}: "
f"expected {expected_count}, found {actual_count}"
)
data = data.replace(old, new)
replacements[old.decode("ascii")] = actual_count
intermediate_hash = hashlib.sha256(data).hexdigest()
if intermediate_hash != KNOWN_PATCHED_LAUNCHER_SHA256:
raise LauncherError(
"known launcher font fallback patch produced an unexpected SHA-256: "
f"{intermediate_hash}"
)
news_old = b"FontAwesome#"
news_new = b"LatoRegular#"
news_end = _NEWS_FEED_FONT_OFFSET + len(news_old)
if data[_NEWS_FEED_FONT_OFFSET:news_end] != news_old:
raise LauncherError("known NewsFeed font offset no longer matches")
data = (
data[:_NEWS_FEED_FONT_OFFSET]
+ news_new
+ data[news_end:]
)
replacements[f"FontAwesome#@{_NEWS_FEED_FONT_OFFSET}"] = 1
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
try:
temporary.write_bytes(data)
shutil.copymode(source, temporary)
destination_hash = _sha256(temporary)
if supported and destination_hash != KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256:
raise LauncherError(
"known launcher patch produced an unexpected SHA-256: "
f"{destination_hash}"
)
temporary.replace(destination)
except BaseException:
temporary.unlink(missing_ok=True)
raise
return LauncherPatchResult(
source=str(source),
destination=str(destination),
source_sha256=source_hash,
destination_sha256=destination_hash,
patched=supported,
supported_profile=supported,
replacements=replacements,
)
def launcher_environment(paths: AppPaths, tools: WineTools) -> dict[str, str]:
return {
"WINEPREFIX": str(paths.launcher_prefix),
"WINE": str(tools.wine),
"WINEDEBUG": "-all",
"WINEESYNC": "1",
"DOTNET_SYSTEM_GLOBALIZATION_USENLS": "1",
}
def configure_launcher_prefix(
paths: AppPaths,
tools: WineTools,
runner: CommandRunner,
*,
force_runtime: bool = False,
) -> list[CommandOutcome]:
"""Create the launcher prefix, install .NET Desktop 6, and apply WPF fixes."""
paths.create_base_directories()
if not runner.dry_run:
paths.launcher_prefix.mkdir(parents=True, exist_ok=True)
environment = launcher_environment(paths, tools)
marker = _read_json(paths.launcher_prefix_marker)
if not force_runtime and _launcher_prefix_ready(paths, tools, marker):
if not runner.dry_run and "configuration_version" not in marker:
_write_json(
paths.launcher_prefix_marker,
{
**marker,
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
},
)
return []
results = [
runner.run(
[str(tools.wineboot), "-u"],
env_overrides={**environment, "WINEARCH": "win64"},
timeout_seconds=300.0,
)
]
if force_runtime or not _dotnet_desktop_ready(paths, marker):
results.append(
runner.run(
[str(tools.winetricks), "-q", "dotnetdesktop6"],
env_overrides=environment,
timeout_seconds=1800.0,
)
)
registry_commands: Iterable[list[str]] = (
[
str(tools.wine), "reg", "add",
r"HKCU\Software\Microsoft\Avalon.Graphics",
"/v", "DisableHWAcceleration", "/t", "REG_DWORD", "/d", "1", "/f",
],
[
str(tools.wine), "reg", "add",
r"HKCU\Software\Microsoft\Avalon.Graphics",
"/v", "MaxMultisampleType", "/t", "REG_DWORD", "/d", "0", "/f",
],
)
for command in registry_commands:
results.append(
runner.run(command, env_overrides=environment, timeout_seconds=60.0)
)
for requested, replacement in (
("Segoe UI", "Carlito"),
("Segoe UI Regular", "Carlito"),
("Segoe UI Light", "Carlito"),
("Segoe UI Semilight", "Carlito"),
("Segoe UI Semibold", "Carlito"),
("Segoe UI Bold", "Carlito"),
("Segoe UI Black", "Carlito"),
("Segoe UI Symbol", "Noto Sans Symbols 2"),
("Lato Regular", "Lato"),
):
results.append(
runner.run(
[
str(tools.wine), "reg", "add",
r"HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes",
"/v", requested, "/t", "REG_SZ", "/d", replacement, "/f",
],
env_overrides=environment,
timeout_seconds=60.0,
)
)
if not runner.dry_run:
_write_json(
paths.launcher_prefix_marker,
{
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
"configured_at": datetime.now().astimezone().isoformat(),
"dotnetdesktop6": True,
"wine": str(tools.wine),
},
)
return results
def launch_official_updater(
paths: AppPaths,
tools: WineTools,
runner: CommandRunner,
) -> CommandOutcome:
if not paths.launcher_patched.is_file() and not runner.dry_run:
raise LauncherError("prepared launcher is missing; run fetch first")
return runner.run(
[str(tools.wine), str(paths.launcher_patched)],
cwd=paths.launcher_patched.parent,
env_overrides=launcher_environment(paths, tools),
timeout_seconds=4 * 60 * 60,
)
def patch_result_dict(result: LauncherPatchResult) -> dict[str, object]:
return asdict(result)
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 _read_json(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _dotnet_desktop_ready(paths: AppPaths, marker: dict[str, object]) -> bool:
if not bool(marker.get("dotnetdesktop6")):
return False
desktop_root = (
paths.launcher_prefix
/ "drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"
)
try:
return any(path.is_dir() and path.name.startswith("6.") for path in desktop_root.iterdir())
except OSError:
return False
def _launcher_prefix_ready(
paths: AppPaths,
tools: WineTools,
marker: dict[str, object],
) -> bool:
configuration_version = marker.get(
"configuration_version",
1,
)
return (
configuration_version == LAUNCHER_PREFIX_CONFIGURATION_VERSION
and marker.get("wine") == str(tools.wine)
and _dotnet_desktop_ready(paths, marker)
)
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)

113
ptlinux/launcher_wrapper.py Normal file
View File

@@ -0,0 +1,113 @@
"""Render the installed command wrapper with persistent application paths."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
from typing import Sequence
APP_ROOT_TOKEN = "@PTLINUX_APP_ROOT@"
INSTALL_DIRECTORY_TOKEN = "@PTLINUX_INSTALL_DIR@"
class LauncherWrapperError(ValueError):
"""Raised when a safe launcher wrapper cannot be rendered."""
def quote_shell_word(value: str) -> str:
"""Quote one value as a literal POSIX shell word."""
if not value or any(character in value for character in ("\x00", "\n", "\r")):
raise LauncherWrapperError("launcher path contains invalid characters")
return "'" + value.replace("'", "'\"'\"'") + "'"
def _absolute_directory(path: Path, label: str) -> Path:
directory = path.expanduser()
if not directory.is_absolute():
raise LauncherWrapperError(f"{label} must be absolute")
return directory
def render_launcher_wrapper(
template: str,
app_root: Path,
install_directory: Path,
) -> str:
"""Embed the selected install layout in one command-wrapper template."""
for token in (APP_ROOT_TOKEN, INSTALL_DIRECTORY_TOKEN):
if template.count(token) != 1:
raise LauncherWrapperError(
f"launcher template must contain exactly one {token} token"
)
application = _absolute_directory(app_root, "application directory")
installation = _absolute_directory(install_directory, "install directory")
if not any(
(candidate / "ptlinux").is_dir()
for candidate in (
application / "current",
application / "previous",
application,
)
):
raise LauncherWrapperError(f"application files are missing: {application}")
rendered = template.replace(APP_ROOT_TOKEN, quote_shell_word(str(application)))
rendered = rendered.replace(
INSTALL_DIRECTORY_TOKEN,
quote_shell_word(str(installation)),
)
return rendered if rendered.endswith("\n") else rendered + "\n"
def install_launcher_wrapper(
template: Path,
app_root: Path,
install_directory: Path,
destination: Path,
) -> None:
"""Render and atomically install one executable command wrapper."""
rendered = render_launcher_wrapper(
template.read_text(encoding="utf-8"),
app_root,
install_directory,
)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
try:
temporary.write_text(rendered, encoding="utf-8")
temporary.chmod(0o755)
temporary.replace(destination)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="install the PristonTale EU command wrapper"
)
parser.add_argument("template", type=Path)
parser.add_argument("app_root", type=Path)
parser.add_argument("install_directory", type=Path)
parser.add_argument("destination", type=Path)
arguments = parser.parse_args(argv)
try:
install_launcher_wrapper(
arguments.template,
arguments.app_root,
arguments.install_directory,
arguments.destination,
)
except (LauncherWrapperError, OSError) as exc:
print(f"Could not install command wrapper: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

42
ptlinux/locking.py Normal file
View File

@@ -0,0 +1,42 @@
"""Cross-process locks for launcher-prefix mutations."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator
class OperationBusyError(RuntimeError):
"""Raised when another launcher-prefix operation is still active."""
@contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
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 as exc:
handle.seek(0)
owner = handle.read().strip() or "unknown operation"
raise OperationBusyError(
f"launcher files are busy ({owner}); close the updater and try again"
) from exc
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
try:
yield
finally:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)

354
ptlinux/news.py Normal file
View File

@@ -0,0 +1,354 @@
"""Official news feed support for the native Linux dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
from typing import Callable, Protocol
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen
NEWS_ENDPOINT = "https://pristontale.eu/api/news/articles"
NEWS_ARTICLE_URL = "https://pristontale.eu/news/"
NEWS_ORIGIN = "https://pristontale.eu"
OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_small.png"
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
MAX_NEWS_BYTES = 2 * 1024 * 1024
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
class NewsError(RuntimeError):
"""Raised when the official news feed cannot be parsed safely."""
class NewsResponse(Protocol):
"""The minimal response interface used by the news client."""
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> NewsResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
NewsOpener = Callable[..., NewsResponse]
@dataclass(frozen=True)
class NewsArticle:
id: int
slug: str
title: str
summary: str
author: str | None
published_at: str | None
image_url: str | None = None
@property
def url(self) -> str:
return f"{NEWS_ARTICLE_URL}{self.slug}"
@dataclass(frozen=True)
class Broadcast:
"""The latest announcement from the official broadcast API."""
author: str
content: str
published_at: str | None
url: str
def fetch_news_image(
article: NewsArticle,
cache_directory: Path,
*,
timeout_seconds: float = 15.0,
opener: NewsOpener = urlopen,
) -> bytes | None:
"""Return one verified PNG thumbnail, preferring the local image cache.
The official feed currently serves PNG article art. Deliberately accepting
only PNG keeps the Tk UI dependency-free and avoids treating arbitrary remote
data as a displayable image format.
"""
if article.image_url is None:
return None
cache_path = _image_cache_path(cache_directory, article)
cached = _read_png(cache_path)
if cached is not None:
return cached
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
def fetch_official_logo(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: NewsOpener = urlopen,
) -> bytes | None:
"""Return the official PristonTale EU PNG logo, using its local cache first."""
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
def _fetch_png_image(
url: str,
cache_path: Path,
timeout_seconds: float,
opener: NewsOpener,
) -> bytes | None:
cached = _read_png(cache_path)
if cached is not None:
return cached
request = Request(
url,
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_NEWS_IMAGE_BYTES + 1)
except OSError:
return None
if len(payload) > MAX_NEWS_IMAGE_BYTES or not _is_png(payload):
return None
_write_image_cache(cache_path, payload)
return payload
def fetch_news(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: NewsOpener = urlopen,
) -> tuple[NewsArticle, ...]:
"""Fetch fresh official news, falling back to the last valid local cache."""
request = Request(
NEWS_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_NEWS_BYTES + 1)
if len(payload) > MAX_NEWS_BYTES:
raise NewsError("official news response exceeds the 2 MiB limit")
articles = _parse_news(payload)
_write_cache(cache_path, articles)
return articles
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
return _read_cache(cache_path)
def fetch_latest_broadcast(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: NewsOpener = urlopen,
) -> Broadcast | None:
"""Fetch the first broadcast supplied by the official broadcast service."""
request = Request(
BROADCAST_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_NEWS_BYTES + 1)
if len(payload) > MAX_NEWS_BYTES:
raise NewsError("broadcast response exceeds the 2 MiB limit")
broadcast = _parse_latest_broadcast(payload)
_write_broadcast_cache(cache_path, broadcast)
return broadcast
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
return _read_broadcast_cache(cache_path)
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
value = json.loads(payload.decode("utf-8-sig"))
if isinstance(value, dict):
value = value.get("data")
if not isinstance(value, list):
raise NewsError("official news response is not an article list")
articles: list[NewsArticle] = []
for item in value[:20]:
if not isinstance(item, dict):
continue
article_id = item.get("id")
slug = item.get("slug")
title = item.get("title")
summary = item.get("summary")
if (
isinstance(article_id, bool)
or not isinstance(article_id, int)
or not isinstance(slug, str)
or not slug.strip()
or "/" in slug
or not isinstance(title, str)
or not title.strip()
or not isinstance(summary, str)
):
continue
author = item.get("author")
date = item.get("date")
articles.append(
NewsArticle(
id=article_id,
slug=slug.strip(),
title=title.strip(),
summary=summary.strip(),
author=author.strip() if isinstance(author, str) and author.strip() else None,
published_at=_format_timestamp(date),
image_url=_image_url(item.get("image")),
)
)
if not articles:
raise NewsError("official news response contains no usable articles")
return tuple(articles)
def _parse_latest_broadcast(payload: bytes) -> Broadcast:
value = json.loads(payload.decode("utf-8-sig"))
if not isinstance(value, list) or not value or not isinstance(value[0], dict):
raise NewsError("broadcast response does not contain a latest broadcast")
latest = value[0]
author = latest.get("author")
content = latest.get("content")
url = _broadcast_url(latest.get("url"))
if (
not isinstance(author, str)
or not author.strip()
or not isinstance(content, str)
or not content.strip()
or url is None
):
raise NewsError("latest broadcast is incomplete")
return Broadcast(
author=author.strip(),
content=content.strip(),
published_at=_format_timestamp(latest.get("timestamp")),
url=url,
)
def _format_timestamp(value: object) -> str | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
except (OverflowError, OSError, ValueError):
return None
def _image_url(value: object) -> str | None:
"""Accept only an official, relative article-art path from the feed."""
if not isinstance(value, str) or not value:
return None
parsed = urlparse(value)
if parsed.scheme or parsed.netloc or not value.startswith("/"):
return None
if not parsed.path.startswith("/storage/media/images/"):
return None
if any(part in ("", ".", "..") for part in parsed.path.split("/")[1:]):
return None
return urljoin(NEWS_ORIGIN, value)
def _broadcast_url(value: object) -> str | None:
if not isinstance(value, str) or not value.strip():
return None
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return value.strip()
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, list):
return ()
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
except (OSError, TypeError, json.JSONDecodeError):
return ()
def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> 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(article) for article in articles], indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)
def _read_broadcast_cache(path: Path) -> Broadcast | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
return Broadcast(**value) if isinstance(value, dict) else None
except (OSError, TypeError, json.JSONDecodeError):
return None
def _write_broadcast_cache(path: Path, broadcast: Broadcast) -> 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(broadcast), indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)
def _image_cache_path(cache_directory: Path, article: NewsArticle) -> Path:
assert article.image_url is not None
digest = hashlib.sha256(article.image_url.encode("utf-8")).hexdigest()[:16]
return cache_directory / f"{article.id}-{digest}.png"
def _read_png(path: Path) -> bytes | None:
try:
payload = path.read_bytes()
except OSError:
return None
return payload if _is_png(payload) else None
def _is_png(payload: bytes) -> bool:
return PNG_SIGNATURE == payload[:len(PNG_SIGNATURE)] and len(payload) <= MAX_NEWS_IMAGE_BYTES
def _write_image_cache(path: Path, payload: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_bytes(payload)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)

418
ptlinux/overlay.py Normal file
View File

@@ -0,0 +1,418 @@
"""Updater-safe runtime view with one verified normalized scene cache."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
from typing import Mapping
import uuid
from ptdiag.assbin import (
SceneInspection,
inspect_wrapped_assbin,
normalize_wrapped_assbin_textures,
)
from .paths import AppPaths
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
KNOWN_SOURCE_SHA256 = (
"1c9655945882b6b2189bbc6961018fa84295f895330987bad34e8428e9b5ce90"
)
TEXTURE_INDICES = (37, 38)
KNOWN_NORMALIZED_SCANLINES_SHA256 = {
37: "8fcc54e8d30fbfb725bb4c917a123e4f28a228397b4939d2fcfc7e8f3edc84f5",
38: "a8301700ee76b0cbf9d974c1395093623390244f2f92658b352a1c3e19e20f60",
}
COMPATIBILITY_BINARY_HASHES = {
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
"game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09",
}
CURRENT_V21_BINARY_HASHES = {
"Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837",
"game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868",
}
PATCHED_V21_GAME_DLL_SHA256 = (
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
)
class OverlayError(RuntimeError):
"""Raised when a safe runtime view cannot be built."""
@dataclass(frozen=True)
class OverlayResult:
source_game: str
runtime_view: str
source_scene_sha256: str
runtime_scene_sha256: str
scene_action: str
binary_action: str
game_exe_sha256: str
game_dll_sha256: str
created_at: str
links_created: int
@dataclass(frozen=True)
class CompatibilityCacheResult:
source: str
destination: str
game_exe_sha256: str
game_dll_sha256: str
created_at: str
@dataclass(frozen=True)
class RuntimePatchCacheResult:
source: str
destination: str
game_dll_sha256: str
created_at: str
def prepare_runtime_view(
paths: AppPaths,
source_game: Path,
) -> OverlayResult:
"""Build a symlink view and keep the launcher-managed source read-only."""
source = source_game.expanduser().resolve(strict=True)
_validate_game_tree(source)
_validate_runtime_target(paths)
source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene)
action = _scene_action(inspection)
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
paths,
source,
)
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
links_created = 0
try:
build.mkdir(parents=True, exist_ok=False)
top_level_exceptions = {"game"}
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)
links_created += _link_level(
source,
build,
except_names=top_level_exceptions,
)
if game_exe_source != source / "Game.exe":
(build / "Game.exe").symlink_to(game_exe_source)
links_created += 1
if game_dll_source != source / "game.dll":
(build / "game.dll").symlink_to(game_dll_source)
links_created += 1
game_source = source / "game"
game_target = build / "game"
game_target.mkdir()
links_created += _link_level(game_source, game_target, except_names={"maps"})
maps_source = game_source / "maps"
maps_target = game_target / "maps"
maps_target.mkdir()
links_created += _link_level(
maps_source,
maps_target,
except_names={"WhisperingVale.rovug"},
)
runtime_scene = maps_target / "WhisperingVale.rovug"
if action == "normalize-known-profile":
normalization = normalize_wrapped_assbin_textures(
source_scene,
runtime_scene,
TEXTURE_INDICES,
)
validate_scene_normalization(normalization)
else:
shutil.copy2(source_scene, runtime_scene)
if paths.runtime_view.exists() or paths.runtime_view.is_symlink():
paths.runtime_view.replace(backup)
build.replace(paths.runtime_view)
if backup.exists() or backup.is_symlink():
_remove_runtime_tree(backup)
except BaseException:
_remove_runtime_tree(build)
if backup.exists() and not paths.runtime_view.exists():
backup.replace(paths.runtime_view)
raise
result = OverlayResult(
source_game=str(source),
runtime_view=str(paths.runtime_view),
source_scene_sha256=inspection.file_sha256,
runtime_scene_sha256=_sha256(paths.runtime_view / WHISPERING_VALE_RELATIVE),
scene_action=action,
binary_action=binary_action,
game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"),
game_dll_sha256=_sha256(paths.runtime_view / "game.dll"),
created_at=datetime.now().astimezone().isoformat(),
links_created=links_created,
)
_write_json(paths.runtime_manifest, asdict(result))
return result
def cache_compatibility_binaries(
paths: AppPaths,
source_game: Path,
) -> CompatibilityCacheResult:
source = source_game.expanduser().resolve(strict=True)
hashes = _binary_hashes(source)
if hashes != COMPATIBILITY_BINARY_HASHES:
raise OverlayError(
"compatibility source does not match the verified Game.exe/game.dll profile: "
f"{source}"
)
target = paths.compatibility_binaries
if target.exists() or target.is_symlink():
if target.is_dir() and _binary_hashes(target) == COMPATIBILITY_BINARY_HASHES:
return _compatibility_result(paths, source)
raise OverlayError(f"compatibility cache has an unknown profile: {target}")
paths.data.mkdir(parents=True, exist_ok=True)
build = paths.data / f".{target.name}.build-{uuid.uuid4().hex}"
try:
build.mkdir(parents=False, exist_ok=False)
for name in COMPATIBILITY_BINARY_HASHES:
shutil.copy2(source / name, build / name)
if _binary_hashes(build) != COMPATIBILITY_BINARY_HASHES:
raise OverlayError("copied compatibility binaries failed hash verification")
build.replace(target)
except BaseException:
_remove_runtime_tree(build)
raise
result = _compatibility_result(paths, source)
_write_json(paths.compatibility_manifest, asdict(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(
cache_hashes: dict[str, str] | None,
source_hashes: dict[str, str],
runtime_patch_hash: str | None = None,
) -> str:
if runtime_patch_hash is not None:
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
raise OverlayError("runtime patch cache has an unknown binary profile")
if cache_hashes is not None:
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
raise OverlayError("compatibility cache has an unknown binary profile")
if source_hashes == COMPATIBILITY_BINARY_HASHES:
return "verified-source-pair"
if runtime_patch_hash is not None:
if source_hashes != CURRENT_V21_BINARY_HASHES:
raise OverlayError("V21 runtime patch does not match the installed client profile")
return "verified-v21-texture-reclaim"
return "upstream-pair"
def _scene_action(inspection: SceneInspection) -> str:
selected = []
for index in TEXTURE_INDICES:
if index >= len(inspection.embedded_textures):
raise OverlayError(f"scene no longer has embedded texture {index}")
selected.append(inspection.embedded_textures[index])
depths = [texture.png.bit_depth if texture.png is not None else None for texture in selected]
colors = [texture.png.color_type if texture.png is not None else None for texture in selected]
return select_scene_action(inspection.file_sha256, depths, colors)
def select_scene_action(
file_sha256: str,
depths: list[int | None],
colors: list[int | None],
) -> str:
"""Choose a repair only from the exact source hash and decoded PNG profile."""
if file_sha256 == KNOWN_SOURCE_SHA256 and depths == [16, 16] and colors == [2, 2]:
return "normalize-known-profile"
if depths == [8, 8] and colors == [2, 2]:
return "upstream-compatible-copy"
raise OverlayError(
"WhisperingVale.rovug is not a supported release profile; refusing to "
f"modify unknown textures (sha256={file_sha256}, "
f"depths={depths}, colors={colors})"
)
def validate_scene_normalization(report: Mapping[str, object]) -> None:
"""Verify the known repair by decoded pixels, not zlib's encoded bytes.
zlib is allowed to emit different valid DEFLATE streams across versions and
compatible implementations. The old whole-file hash therefore rejected a
semantically identical scene on some distributions. The source file remains
pinned exactly, while these hashes pin the decoded scanlines produced for the
two repaired textures.
"""
if report.get("source_sha256") != KNOWN_SOURCE_SHA256:
raise OverlayError("normalized scene source did not match the verified release profile")
verified = report.get("verified")
if not isinstance(verified, bool) or not verified:
raise OverlayError("normalized scene did not pass structural verification")
if report.get("texture_indices") != list(TEXTURE_INDICES):
raise OverlayError("normalized scene changed an unexpected texture set")
changes = report.get("changes")
if not isinstance(changes, list):
raise OverlayError("normalized scene report did not contain texture changes")
change_hashes: dict[int, str] = {}
for change in changes:
if not isinstance(change, dict):
raise OverlayError("normalized scene report contained an invalid texture change")
index = change.get("index")
scanlines_sha256 = change.get("new_scanlines_sha256")
if isinstance(index, bool) or not isinstance(index, int):
raise OverlayError("normalized scene report contained an invalid texture index")
if not isinstance(scanlines_sha256, str):
raise OverlayError("normalized scene report omitted a decoded texture hash")
if index in change_hashes:
raise OverlayError("normalized scene report repeated a texture change")
change_hashes[index] = scanlines_sha256
if change_hashes != KNOWN_NORMALIZED_SCANLINES_SHA256:
raise OverlayError(
"normalized scene pixels did not match the verified release profile"
)
def _validate_game_tree(source: Path) -> None:
for relative in (
Path("Game.exe"),
Path("game.dll"),
Path("game.ini"),
WHISPERING_VALE_RELATIVE,
):
if not (source / relative).is_file():
raise OverlayError(f"game installation is incomplete: missing {relative}")
def _validate_runtime_target(paths: AppPaths) -> None:
data = paths.data.resolve()
target = paths.runtime_view
if target.parent.resolve() != data:
raise OverlayError(f"runtime view is outside the application data root: {target}")
if target == data:
raise OverlayError("runtime view cannot be the application data root")
def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
count = 0
for child in source.iterdir():
if child.name in except_names:
continue
(target / child.name).symlink_to(child, target_is_directory=child.is_dir())
count += 1
return count
def _runtime_game_binary_sources(paths: AppPaths, source: Path) -> tuple[str, Path, Path]:
cache = paths.compatibility_binaries
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
source_hashes = _binary_hashes(source)
patch = paths.runtime_patch_dll
if source_hashes == CURRENT_V21_BINARY_HASHES and not patch.exists():
try:
result = build_texture_reclaim_patch(source / "game.dll", patch)
except TextureReclaimPatchError as exc:
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
patch_hash = _sha256(patch) if patch.is_file() else None
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
return (
action,
source / "Game.exe",
patch if action == "verified-v21-texture-reclaim" else source / "game.dll",
)
def _binary_hashes(directory: Path) -> dict[str, str]:
return {
name: _sha256(directory / name)
for name in COMPATIBILITY_BINARY_HASHES
if (directory / name).is_file()
}
def _compatibility_result(paths: AppPaths, source: Path) -> CompatibilityCacheResult:
return CompatibilityCacheResult(
source=str(source),
destination=str(paths.compatibility_binaries),
game_exe_sha256=COMPATIBILITY_BINARY_HASHES["Game.exe"],
game_dll_sha256=COMPATIBILITY_BINARY_HASHES["game.dll"],
created_at=datetime.now().astimezone().isoformat(),
)
def _runtime_patch_result(paths: AppPaths, source: Path) -> RuntimePatchCacheResult:
return RuntimePatchCacheResult(
source=str(source),
destination=str(paths.runtime_patch_dll),
game_dll_sha256=PATCHED_V21_GAME_DLL_SHA256,
created_at=datetime.now().astimezone().isoformat(),
)
def _remove_runtime_tree(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path)
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 _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)

219
ptlinux/paths.py Normal file
View File

@@ -0,0 +1,219 @@
"""Per-user game data and XDG support paths used by the installer."""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Mapping
APP_DIRECTORY_NAME = "pristontale-eu"
DEFAULT_INSTALL_DIRECTORY_NAME = "PristontaleEU"
INSTALL_DIRECTORY_VARIABLE = "PTLINUX_INSTALL_DIR"
GAME_RELATIVE_PATH = Path("drive_c/Program Files (x86)/Pristontale EU")
def _xdg_path(
environment: Mapping[str, str],
variable: str,
fallback: Path,
) -> Path:
value = environment.get(variable)
return Path(value).expanduser() if value else fallback
@dataclass(frozen=True)
class AppPaths:
"""All persistent paths for one user's installation."""
data: Path
config: Path
state: Path
cache: Path
@classmethod
def from_environment(
cls,
environment: Mapping[str, str] | None = None,
) -> AppPaths:
env = os.environ if environment is None else environment
home_value = env.get("HOME")
if not home_value:
raise RuntimeError("HOME is not set")
home = Path(home_value).expanduser()
config_home = _xdg_path(env, "XDG_CONFIG_HOME", home / ".config")
state_home = _xdg_path(env, "XDG_STATE_HOME", home / ".local/state")
cache_home = _xdg_path(env, "XDG_CACHE_HOME", home / ".cache")
install_value = env.get(INSTALL_DIRECTORY_VARIABLE)
if install_value:
data = Path(install_value).expanduser()
if not data.is_absolute():
raise RuntimeError(
f"{INSTALL_DIRECTORY_VARIABLE} must be an absolute path"
)
else:
data = home / "Games" / DEFAULT_INSTALL_DIRECTORY_NAME
return cls(
data=data.resolve(),
config=(config_home / APP_DIRECTORY_NAME).resolve(),
state=(state_home / APP_DIRECTORY_NAME).resolve(),
cache=(cache_home / APP_DIRECTORY_NAME).resolve(),
)
@property
def downloads(self) -> Path:
return self.cache / "downloads"
@property
def app_home(self) -> Path:
"""Stable home containing versioned native-launcher releases."""
return self.data / "app"
@property
def launcher_releases(self) -> Path:
return self.app_home / "releases"
@property
def launcher_current(self) -> Path:
return self.app_home / "current"
@property
def launcher_previous(self) -> Path:
return self.app_home / "previous"
@property
def launcher_update_state(self) -> Path:
return self.state / "launcher-update.json"
@property
def launcher_update_lock(self) -> Path:
return self.state / "launcher-update.lock"
def launcher_update_archive(self, version: str) -> Path:
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
@property
def launcher_pristine(self) -> Path:
return self.downloads / "Pristontale EU Launcher.exe"
@property
def launcher_patched(self) -> Path:
return self.data / "launcher" / "Pristontale EU Launcher.exe"
@property
def launcher_metadata(self) -> Path:
return self.state / "launcher-download.json"
@property
def launcher_prefix(self) -> Path:
return self.data / "launcher-prefix"
@property
def launcher_prefix_marker(self) -> Path:
return self.state / "launcher-prefix.json"
@property
def launcher_operation_lock(self) -> Path:
return self.state / "launcher-operation.lock"
@property
def source_game(self) -> Path:
return self.launcher_prefix / GAME_RELATIVE_PATH
@property
def game_import_manifest(self) -> Path:
return self.state / "game-import.json"
@property
def native_depot_state(self) -> Path:
"""Last completed native depot synchronization."""
return self.state / "native-depot.json"
@property
def news_cache(self) -> Path:
return self.cache / "news.json"
@property
def broadcast_cache(self) -> Path:
"""Cached latest announcement from the broadcast service."""
return self.cache / "broadcast.json"
@property
def news_image_cache(self) -> Path:
"""Cached official PNG thumbnails used by the native news cards."""
return self.cache / "news-images"
@property
def logo_cache(self) -> Path:
"""Cached PNG asset for the official PristonTale EU logo."""
return self.cache / "logo-small.png"
@property
def runtime_view(self) -> Path:
return self.data / "runtime-view"
@property
def runtime_manifest(self) -> Path:
return self.state / "runtime-view.json"
@property
def compatibility_binaries(self) -> Path:
return self.data / "compatibility-binaries"
@property
def compatibility_manifest(self) -> Path:
return self.state / "compatibility-binaries.json"
@property
def runtime_patch_directory(self) -> Path:
return self.data / "runtime-patches" / "v21-texture-reclaim"
@property
def runtime_patch_dll(self) -> Path:
return self.runtime_patch_directory / "game.dll"
@property
def runtime_patch_manifest(self) -> Path:
return self.state / "runtime-patch.json"
@property
def compat_data(self) -> Path:
return self.data / "game-compat"
@property
def proton_prefix(self) -> Path:
return self.compat_data / "pfx"
@property
def umu_prefix(self) -> Path:
return self.data / "umu-prefix"
@property
def dxvk_cache(self) -> Path:
return self.cache / "dxvk-state"
@property
def logs(self) -> Path:
return self.state / "logs"
@property
def settings_file(self) -> Path:
return self.config / "settings.json"
def create_base_directories(self) -> None:
for path in (
self.data,
self.config,
self.state,
self.cache,
self.downloads,
self.logs,
self.dxvk_cache,
):
path.mkdir(parents=True, exist_ok=True)

31
ptlinux/proton_compat.py Normal file
View File

@@ -0,0 +1,31 @@
"""Compatibility entry point for retained Valve Proton 7 scripts."""
from __future__ import annotations
from pathlib import Path
import runpy
import sys
import tarfile
from typing import Sequence
def main(argv: Sequence[str] | None = None) -> None:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments:
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
proton = Path(arguments[0]).expanduser().resolve(strict=True)
proton_directory = str(proton.parent)
if proton_directory not in sys.path:
sys.path.insert(0, proton_directory)
if hasattr(tarfile, "fully_trusted_filter"):
setattr(
tarfile.TarFile,
"extraction_filter",
staticmethod(tarfile.fully_trusted_filter),
)
sys.argv = [str(proton), *arguments[1:]]
runpy.run_path(str(proton), run_name="__main__")
if __name__ == "__main__":
main()

390
ptlinux/runtime.py Normal file
View File

@@ -0,0 +1,390 @@
"""Portable game runtime selection and Proton/UMU preparation."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import sys
from typing import Mapping
from .command import CommandOutcome, CommandRunner
from .gamebox import build_gamebox_command
from .paths import AppPaths
from .settings import Settings
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
DXVK_D3D9_OVERRIDE = "native,builtin"
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
WINETRICKS_CODEPAGE_PROBE = (
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
)
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
class RuntimeErrorPT(RuntimeError):
"""Raised when a supported game runtime cannot be prepared."""
@dataclass(frozen=True)
class RuntimeSelection:
kind: str
executable: str
proton_path: str | None
steam_root: str | None
def select_runtime(
settings: Settings,
environment: Mapping[str, str] | None = None,
) -> RuntimeSelection:
env = os.environ if environment is None else environment
if settings.proton_path:
proton = Path(settings.proton_path).expanduser().resolve()
if not proton.is_file():
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
steam_root = _steam_root_for_proton(proton)
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
if settings.runtime in {"auto", "proton7"}:
proton_result = find_steam_proton7(env)
if proton_result is not None:
proton, steam_root = proton_result
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
if settings.runtime == "proton7":
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
if settings.runtime in {"auto", "umu"}:
umu = shutil.which("umu-run")
if umu:
return RuntimeSelection("umu", umu, None, None)
raise RuntimeErrorPT(
"no supported game runtime found; install umu-launcher or Steam Proton 7.0"
)
raise RuntimeErrorPT(f"unsupported runtime selection: {settings.runtime}")
def find_steam_proton7(
environment: Mapping[str, str] | None = None,
) -> tuple[Path, Path] | None:
env = os.environ if environment is None else environment
home_value = env.get("HOME")
if not home_value:
return None
home = Path(home_value).expanduser()
roots: list[Path] = []
explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH")
if explicit:
roots.append(Path(explicit).expanduser())
roots.extend(
(
home / ".local/share/Steam",
home / ".steam/steam",
home / "snap/steam/common/.local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
)
)
seen: set[Path] = set()
for root in roots:
resolved = root.resolve()
if resolved in seen:
continue
seen.add(resolved)
proton = resolved / "steamapps/common/Proton 7.0/proton"
if proton.is_file():
return proton, resolved
return None
def prepare_game_runtime(
paths: AppPaths,
selection: RuntimeSelection,
runner: CommandRunner,
) -> list[CommandOutcome]:
if not runner.dry_run:
paths.create_base_directories()
results: list[CommandOutcome] = []
if selection.kind == "umu":
environment = _umu_environment(paths)
if not native_d3d_ready(paths.umu_prefix):
results.append(
runner.run(
[selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"],
env_overrides=environment,
timeout_seconds=1800.0,
)
)
elif selection.kind == "proton7":
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
if not runner.dry_run:
paths.compat_data.mkdir(parents=True, exist_ok=True)
proton = Path(selection.proton_path)
environment = _proton_environment(paths, selection)
prefix_verb = "runinprefix" if _proton_prefix_ready(paths.proton_prefix) else "run"
results.append(
runner.run(
[
sys.executable,
"-m",
"ptlinux.proton_compat",
str(proton),
prefix_verb,
"cmd",
"/c",
"exit",
],
env_overrides=environment,
timeout_seconds=600.0,
)
)
if not runner.dry_run:
_ensure_proton_game_link(paths)
results.append(
runner.run(
[
sys.executable,
"-m",
"ptlinux.proton_compat",
str(proton),
"runinprefix",
"reg.exe",
"add",
DLL_OVERRIDES_KEY,
"/v",
"d3d9",
"/t",
"REG_SZ",
"/d",
DXVK_D3D9_OVERRIDE,
"/f",
],
env_overrides=environment,
timeout_seconds=60.0,
)
)
if not native_d3d_ready(paths.proton_prefix):
wine_tools = _find_proton_wine_tools(proton)
winetricks = shutil.which("winetricks")
if wine_tools is None or not winetricks:
raise RuntimeErrorPT("Proton Winetricks helpers are unavailable")
wine, wineserver = wine_tools
compatible_winetricks = prepare_proton7_winetricks(
Path(winetricks),
paths.cache / "compat/winetricks-proton7",
dry_run=runner.dry_run,
)
results.append(
runner.run(
[
str(compatible_winetricks),
"-q",
"d3dx9_43",
"d3dcompiler_43",
],
env_overrides={
"WINEPREFIX": str(paths.proton_prefix),
"WINE": str(wine),
"WINESERVER": str(wineserver),
"WINEDEBUG": "-all",
},
timeout_seconds=1800.0,
)
)
else:
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
if not runner.dry_run:
_write_json(
paths.state / "game-runtime.json",
{
"configured_at": datetime.now().astimezone().isoformat(),
"native_d3d43": True,
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
"runtime": asdict(selection),
},
)
return results
def run_game(
paths: AppPaths,
settings: Settings,
selection: RuntimeSelection,
runner: CommandRunner,
*,
use_gamebox: bool = True,
) -> CommandOutcome:
if not paths.runtime_view.is_dir() and not runner.dry_run:
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
if selection.kind == "umu":
game_command = [
selection.executable,
str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
]
environment = _umu_environment(paths)
else:
if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing")
game_command = [
sys.executable,
"-m",
"ptlinux.proton_compat",
selection.proton_path,
"runinprefix",
WINDOWS_GAME_EXE,
"PTEU_REBORN",
]
environment = _proton_environment(paths, selection)
if use_gamebox:
game_command = build_gamebox_command(game_command, settings)
environment["PYTHONPATH"] = _runtime_python_path(
os.environ.get("PYTHONPATH"),
)
return runner.run(
game_command,
cwd=paths.runtime_view,
env_overrides=environment,
timeout_seconds=12 * 60 * 60,
stream_output=True,
)
def _runtime_python_path(existing: str | None) -> str:
"""Make the installed package importable after changing into runtime_view."""
package_root = str(Path(__file__).resolve().parent.parent)
if not existing:
return package_root
return os.pathsep.join((package_root, existing))
def _umu_environment(paths: AppPaths) -> dict[str, str]:
return {
"WINEPREFIX": str(paths.umu_prefix),
"GAMEID": "umu-pristontale-eu",
"STORE": "none",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
"WINEDEBUG": "-all",
}
def _proton_environment(
paths: AppPaths,
selection: RuntimeSelection,
) -> dict[str, str]:
if not selection.steam_root:
raise RuntimeErrorPT("Steam root is missing for Proton")
return {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
"SteamAppId": "0",
"SteamGameId": "0",
"WINEPREFIX": str(paths.proton_prefix),
"WINEDEBUG": "-all",
"PROTON_LOG": "0",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
}
def _ensure_proton_game_link(paths: AppPaths) -> None:
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
link.parent.mkdir(parents=True, exist_ok=True)
if link.is_symlink():
if link.resolve() == paths.runtime_view.resolve():
return
raise RuntimeErrorPT(f"Proton game link points somewhere unexpected: {link}")
if link.exists():
if link.is_dir() and not any(link.iterdir()):
link.rmdir()
else:
raise RuntimeErrorPT(f"refusing to replace existing Proton game path: {link}")
link.symlink_to(paths.runtime_view, target_is_directory=True)
def _steam_root_for_proton(proton: Path) -> Path:
parts = proton.parts
try:
index = parts.index("steamapps")
except ValueError as exc:
raise RuntimeErrorPT(
"configured Proton path must be inside a Steam steamapps directory"
) from exc
return Path(*parts[:index])
def native_d3d_ready(prefix: Path) -> bool:
directory = prefix / "drive_c/windows/syswow64"
if not all((directory / f"{name}.dll").is_file() for name in NATIVE_D3D43_DLLS):
return False
try:
registry = (prefix / "user.reg").read_text(
encoding="utf-8",
errors="replace",
).casefold()
except OSError:
return False
return all(_native_override_ready(registry, name) for name in NATIVE_D3D43_DLLS)
def _native_override_ready(registry: str, name: str) -> bool:
return any(
entry in registry
for entry in (
f'"{name}"="native,builtin"',
f'"{name}"="native"',
f'"*{name}"="native"',
)
)
def prepare_proton7_winetricks(
source: Path,
destination: Path,
*,
dry_run: bool = False,
) -> Path:
try:
content = source.read_text(encoding="utf-8")
except OSError as exc:
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
if WINETRICKS_CODEPAGE_PROBE not in content:
return source
if dry_run:
return destination
destination.parent.mkdir(parents=True, exist_ok=True)
patched = content.replace(
WINETRICKS_CODEPAGE_PROBE,
WINETRICKS_PROTON7_PROBE,
1,
)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
temporary.write_text(patched, encoding="utf-8")
temporary.chmod(source.stat().st_mode & 0o777)
temporary.replace(destination)
return destination
def _proton_prefix_ready(prefix: Path) -> bool:
return (prefix / "user.reg").is_file() and (
prefix / "drive_c/windows/system32/cmd.exe"
).exists()
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
wine = directory / "wine"
wineserver = directory / "wineserver"
if wine.is_file() and wineserver.is_file():
return wine, wineserver
return None
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)

857
ptlinux/self_update.py Normal file
View File

@@ -0,0 +1,857 @@
"""Signed, atomic self-updates for the native Linux launcher."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
from typing import Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen
from . import __version__
from .locking import launcher_operation_lock
from .paths import AppPaths
DEFAULT_UPDATE_REPOSITORY_URL = (
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
DEFAULT_UPDATE_MANIFEST_URL = (
f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json"
)
UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL"
UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY"
PUBLIC_KEY_FILENAME = "launcher-update-public.pem"
USER_AGENT = f"PT-Linux/{__version__} launcher-updater"
READ_SIZE = 256 * 1024
MAX_MANIFEST_BYTES = 128 * 1024
MAX_SIGNATURE_BYTES = 16 * 1024
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
MAX_EXTRACTED_BYTES = 64 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 512
UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v"
_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z")
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
class LauncherUpdateError(RuntimeError):
"""Raised when a launcher update cannot be trusted or installed safely."""
@dataclass(frozen=True)
class UpdateManifest:
schema: int
channel: str
version: str
minimum_updater_version: str
archive_url: str
archive_sha256: str
archive_size: int
published_at: str
notes_url: str | None
@dataclass(frozen=True)
class LauncherUpdateCheck:
current_version: str
latest_version: str
available: bool
supported: bool
reason: str
manifest_url: str
manifest: UpdateManifest
@dataclass(frozen=True)
class LauncherUpdateResult:
changed: bool
current_version: str
installed_version: str
release_directory: str | None
previous_release: str | None
archive: str | None
archive_sha256: str | None
restarted_required: bool
@dataclass(frozen=True)
class LauncherRollbackResult:
from_version: str
to_version: str
current_release: str
previous_release: str
restart_required: bool
def default_public_key_path() -> Path:
return Path(__file__).with_name(PUBLIC_KEY_FILENAME)
def configured_manifest_url(
environment: Mapping[str, str] | None = None,
) -> str:
env = os.environ if environment is None else environment
return env.get(UPDATE_MANIFEST_URL_VARIABLE, DEFAULT_UPDATE_MANIFEST_URL)
def configured_public_key_path(
environment: Mapping[str, str] | None = None,
) -> Path:
env = os.environ if environment is None else environment
configured = env.get(UPDATE_PUBLIC_KEY_VARIABLE)
return Path(configured).expanduser() if configured else default_public_key_path()
def check_for_launcher_update(
*,
current_version: str = __version__,
manifest_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
timeout_seconds: float = 20.0,
) -> LauncherUpdateCheck:
"""Fetch and authenticate the stable update manifest."""
_parse_version(current_version, "current launcher version")
url = manifest_url or configured_manifest_url(environment)
_require_https_url(url, "update manifest URL")
key = public_key_path or configured_public_key_path(environment)
manifest_bytes = _fetch_small_file(
url,
maximum_bytes=MAX_MANIFEST_BYTES,
timeout_seconds=timeout_seconds,
label="update manifest",
)
signature_bytes = _fetch_small_file(
_signature_url(url),
maximum_bytes=MAX_SIGNATURE_BYTES,
timeout_seconds=timeout_seconds,
label="update manifest signature",
)
_verify_manifest_signature(manifest_bytes, signature_bytes, key)
manifest = parse_update_manifest(manifest_bytes)
current = _parse_version(current_version, "current launcher version")
latest = _parse_version(manifest.version, "manifest version")
minimum = _parse_version(
manifest.minimum_updater_version,
"minimum updater version",
)
available = latest > current
supported = current >= minimum
if not available:
reason = (
"This launcher is newer than the stable update channel."
if latest < current
else "The launcher is up to date."
)
elif not supported:
reason = (
f"Version {manifest.version} requires updater "
f"{manifest.minimum_updater_version} or newer; install it manually."
)
else:
reason = f"Launcher {manifest.version} is available."
return LauncherUpdateCheck(
current_version=current_version,
latest_version=manifest.version,
available=available,
supported=supported,
reason=reason,
manifest_url=url,
manifest=manifest,
)
def install_launcher_update(
paths: AppPaths,
*,
manifest_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
timeout_seconds: float = 60.0,
) -> LauncherUpdateResult:
"""Authenticate, stage, validate, and atomically activate one update."""
check = check_for_launcher_update(
manifest_url=manifest_url,
public_key_path=public_key_path,
environment=environment,
timeout_seconds=min(timeout_seconds, 30.0),
)
if not check.available:
return LauncherUpdateResult(
changed=False,
current_version=check.current_version,
installed_version=check.current_version,
release_directory=None,
previous_release=None,
archive=None,
archive_sha256=None,
restarted_required=False,
)
if not check.supported:
raise LauncherUpdateError(check.reason)
paths.create_base_directories()
with launcher_operation_lock(paths.launcher_update_lock, "launcher-update"):
archive = paths.launcher_update_archive(check.manifest.version)
_download_archive(
check.manifest,
archive,
timeout_seconds=timeout_seconds,
)
release_directory = _stage_and_install_release(paths, archive, check.manifest)
previous = _activate_release(paths, release_directory)
_write_json(
paths.launcher_update_state,
{
"schema": 1,
"updated_at": datetime.now().astimezone().isoformat(),
"from_version": check.current_version,
"to_version": check.manifest.version,
"manifest_url": check.manifest_url,
"archive_url": check.manifest.archive_url,
"archive_sha256": check.manifest.archive_sha256,
"release_directory": str(release_directory),
"previous_release": str(previous) if previous else None,
},
)
return LauncherUpdateResult(
changed=True,
current_version=check.current_version,
installed_version=check.manifest.version,
release_directory=str(release_directory),
previous_release=str(previous) if previous else None,
archive=str(archive),
archive_sha256=check.manifest.archive_sha256,
restarted_required=True,
)
def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
"""Swap the current and previous authenticated launcher releases."""
with launcher_operation_lock(paths.launcher_update_lock, "launcher-rollback"):
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
previous = _release_from_link(paths.launcher_previous, paths.launcher_releases)
if current is None:
raise LauncherUpdateError("the current launcher release link is missing")
if previous is None:
raise LauncherUpdateError("no previous launcher release is available")
current_version = _validate_release_directory(current)
previous_version = _validate_release_directory(previous)
if current == previous:
raise LauncherUpdateError("current and previous launcher releases are identical")
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases)
_replace_release_link(paths.launcher_current, previous, paths.launcher_releases)
_fsync_directory(paths.app_home)
_write_json(
paths.launcher_update_state,
{
"schema": 1,
"rolled_back_at": datetime.now().astimezone().isoformat(),
"from_version": current_version,
"to_version": previous_version,
"release_directory": str(previous),
"previous_release": str(current),
},
)
return LauncherRollbackResult(
from_version=current_version,
to_version=previous_version,
current_release=str(previous),
previous_release=str(current),
restart_required=True,
)
def parse_update_manifest(data: bytes) -> UpdateManifest:
"""Strictly parse one already-authenticated manifest."""
if not data or len(data) > MAX_MANIFEST_BYTES:
raise LauncherUpdateError("update manifest has an invalid size")
try:
value = json.loads(
data.decode("utf-8"),
object_pairs_hook=_unique_json_object,
)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"update manifest is invalid JSON: {exc}") from exc
if not isinstance(value, dict):
raise LauncherUpdateError("update manifest must contain a JSON object")
schema = _required_integer(value, "schema")
if schema != 1:
raise LauncherUpdateError(f"unsupported update manifest schema: {schema}")
channel = _required_string(value, "channel")
if channel != "stable":
raise LauncherUpdateError(f"unsupported update channel: {channel}")
version = _required_string(value, "version")
minimum = _required_string(value, "minimum_updater_version")
_parse_version(version, "manifest version")
_parse_version(minimum, "minimum updater version")
archive_url = _required_string(value, "archive_url")
_require_https_url(archive_url, "update archive URL")
archive_sha256 = _required_string(value, "archive_sha256")
if _SHA256_PATTERN.fullmatch(archive_sha256) is None:
raise LauncherUpdateError("archive_sha256 must be a lowercase SHA-256 digest")
archive_size = _required_integer(value, "archive_size")
if archive_size <= 0 or archive_size > MAX_ARCHIVE_BYTES:
raise LauncherUpdateError(
f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
)
published_at = _required_string(value, "published_at")
notes_value = value.get("notes_url")
if notes_value is not None and not isinstance(notes_value, str):
raise LauncherUpdateError("notes_url must be a string or null")
if notes_value:
_require_https_url(notes_value, "release notes URL")
return UpdateManifest(
schema=schema,
channel=channel,
version=version,
minimum_updater_version=minimum,
archive_url=archive_url,
archive_sha256=archive_sha256,
archive_size=archive_size,
published_at=published_at,
notes_url=notes_value or None,
)
def _fetch_small_file(
url: str,
*,
maximum_bytes: int,
timeout_seconds: float,
label: str,
) -> bytes:
request = Request(
url,
headers={
"User-Agent": USER_AGENT,
"Accept": "application/octet-stream, application/json",
"Accept-Encoding": "identity",
},
method="GET",
)
try:
response = urlopen(request, timeout=timeout_seconds)
except HTTPError as exc:
raise LauncherUpdateError(f"{label} request failed: HTTP {exc.code}") from exc
except (URLError, OSError) as exc:
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
with response:
_require_https_url(response.geturl(), f"final {label} URL")
length = _content_length(response.headers.get("Content-Length"), label)
if length is not None and length > maximum_bytes:
raise LauncherUpdateError(f"{label} exceeds its size limit")
data = response.read(maximum_bytes + 1)
if len(data) > maximum_bytes:
raise LauncherUpdateError(f"{label} exceeds its size limit")
if length is not None and len(data) != length:
raise LauncherUpdateError(f"{label} response was incomplete")
return data
def _verify_manifest_signature(
manifest: bytes,
signature: bytes,
public_key_path: Path,
) -> None:
if not signature or len(signature) > MAX_SIGNATURE_BYTES:
raise LauncherUpdateError("update manifest signature has an invalid size")
key = public_key_path.expanduser().resolve()
if not key.is_file():
raise LauncherUpdateError(f"launcher update public key is missing: {key}")
openssl = shutil.which("openssl")
if not openssl:
raise LauncherUpdateError("OpenSSL is required to verify launcher updates")
with tempfile.TemporaryDirectory(prefix="ptlinux-update-signature-") as temporary:
root = Path(temporary)
manifest_path = root / "stable.json"
signature_path = root / "stable.json.sig"
manifest_path.write_bytes(manifest)
signature_path.write_bytes(signature)
try:
result = subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-verify",
str(key),
"-signature",
str(signature_path),
str(manifest_path),
],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc
if result.returncode != 0 or "Verified OK" not in result.stdout:
raise LauncherUpdateError("launcher update manifest signature is invalid")
def _download_archive(
manifest: UpdateManifest,
destination: Path,
*,
timeout_seconds: float,
) -> None:
if destination.is_file() and _archive_matches(destination, manifest):
return
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part")
temporary.unlink(missing_ok=True)
request = Request(
manifest.archive_url,
headers={
"User-Agent": USER_AGENT,
"Accept": "application/gzip, application/octet-stream",
"Accept-Encoding": "identity",
},
method="GET",
)
try:
try:
response = urlopen(request, timeout=timeout_seconds)
except HTTPError as exc:
raise LauncherUpdateError(
f"launcher archive request failed: HTTP {exc.code}"
) from exc
except (URLError, OSError) as exc:
raise LauncherUpdateError(f"launcher archive request failed: {exc}") from exc
with response:
_require_https_url(response.geturl(), "final update archive URL")
length = _content_length(
response.headers.get("Content-Length"),
"launcher archive",
)
if length is not None and length != manifest.archive_size:
raise LauncherUpdateError(
"launcher archive Content-Length does not match the signed manifest"
)
digest = hashlib.sha256()
written = 0
with temporary.open("xb") as handle:
while True:
chunk = response.read(READ_SIZE)
if not chunk:
break
written += len(chunk)
if written > manifest.archive_size or written > MAX_ARCHIVE_BYTES:
raise LauncherUpdateError("launcher archive exceeded its signed size")
digest.update(chunk)
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
if written != manifest.archive_size:
raise LauncherUpdateError(
f"launcher archive was incomplete: {written}/{manifest.archive_size} bytes"
)
if digest.hexdigest() != manifest.archive_sha256:
raise LauncherUpdateError("launcher archive SHA-256 does not match the manifest")
temporary.replace(destination)
_fsync_directory(destination.parent)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def _stage_and_install_release(
paths: AppPaths,
archive: Path,
manifest: UpdateManifest,
) -> Path:
if paths.app_home.is_symlink():
raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}")
paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755)
paths.app_home.chmod(0o755)
if paths.launcher_releases.is_symlink():
raise LauncherUpdateError(
f"launcher release directory cannot be a link: {paths.launcher_releases}"
)
paths.launcher_releases.mkdir(parents=True, exist_ok=True, mode=0o755)
paths.launcher_releases.chmod(0o755)
staging = paths.launcher_releases / f".{manifest.version}.{os.getpid()}.staging"
if staging.exists() or staging.is_symlink():
raise LauncherUpdateError(f"launcher staging path already exists: {staging}")
staging.mkdir(mode=0o700)
try:
_extract_release_archive(archive, staging, manifest.version)
staged_version = _validate_release_directory(staging)
if staged_version != manifest.version:
raise LauncherUpdateError(
f"staged launcher version is {staged_version}, expected {manifest.version}"
)
_self_check_release(staging, manifest.version)
staging.chmod(0o755)
destination = paths.launcher_releases / manifest.version
if destination.exists() or destination.is_symlink():
if not destination.is_dir() or destination.is_symlink():
raise LauncherUpdateError(
f"launcher release destination is not a directory: {destination}"
)
if _tree_digest(destination) != _tree_digest(staging):
raise LauncherUpdateError(
f"launcher release {manifest.version} already exists with different contents"
)
shutil.rmtree(staging)
else:
staging.replace(destination)
_fsync_directory(paths.launcher_releases)
return destination
except BaseException:
if staging.exists() and staging.is_dir() and not staging.is_symlink():
shutil.rmtree(staging)
raise
def _extract_release_archive(archive: Path, destination: Path, version: str) -> None:
expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}"
seen: set[PurePosixPath] = set()
total_bytes = 0
try:
bundle = tarfile.open(archive, "r:gz")
except (OSError, tarfile.TarError) as exc:
raise LauncherUpdateError(f"launcher archive cannot be opened: {exc}") from exc
with bundle:
members = bundle.getmembers()
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
raise LauncherUpdateError("launcher archive has an invalid member count")
for member in members:
name = member.name[:-1] if member.isdir() and member.name.endswith("/") else member.name
if not name or "\\" in name or "\x00" in name:
raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}")
raw_parts = name.split("/")
if any(part in {"", ".", ".."} for part in raw_parts):
raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}")
path = PurePosixPath(name)
if path.is_absolute() or not path.parts or path.parts[0] != expected_root:
raise LauncherUpdateError(
f"launcher archive member is outside {expected_root}: {member.name!r}"
)
relative = PurePosixPath(*path.parts[1:])
if not relative.parts:
if not member.isdir():
raise LauncherUpdateError("launcher archive root must be a directory")
continue
if relative.parts[0] not in _ALLOWED_RELEASE_ROOTS:
raise LauncherUpdateError(
f"unexpected launcher payload path: {relative.as_posix()}"
)
if relative in seen:
raise LauncherUpdateError(
f"duplicate launcher archive path: {relative.as_posix()}"
)
seen.add(relative)
if not member.isdir() and not member.isfile():
raise LauncherUpdateError(
f"unsupported launcher archive member: {member.name!r}"
)
if member.mode & 0o022:
raise LauncherUpdateError(
f"launcher archive member is group/world writable: {member.name!r}"
)
target = destination.joinpath(*relative.parts)
if member.isdir():
target.mkdir(parents=True, exist_ok=True, mode=0o755)
continue
if member.size < 0:
raise LauncherUpdateError("launcher archive contains a negative file size")
total_bytes += member.size
if total_bytes > MAX_EXTRACTED_BYTES:
raise LauncherUpdateError("launcher archive exceeds its extraction limit")
target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
source = bundle.extractfile(member)
if source is None:
raise LauncherUpdateError(f"cannot read launcher payload: {member.name!r}")
written = 0
with source, target.open("xb") as handle:
while True:
chunk = source.read(READ_SIZE)
if not chunk:
break
written += len(chunk)
if written > member.size:
raise LauncherUpdateError(
f"launcher payload exceeded its declared size: {member.name!r}"
)
handle.write(chunk)
if written != member.size:
raise LauncherUpdateError(
f"launcher payload was incomplete: {member.name!r}"
)
target.chmod(0o644)
def _validate_release_directory(release: Path) -> str:
if not release.is_dir() or release.is_symlink():
raise LauncherUpdateError(f"launcher release directory is invalid: {release}")
for item in (release, *release.rglob("*")):
if item.is_symlink():
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
try:
mode = item.stat().st_mode
except OSError as exc:
raise LauncherUpdateError(f"cannot inspect launcher release path {item}: {exc}") from exc
if mode & 0o022:
raise LauncherUpdateError(
f"launcher release path is group/world writable: {item}"
)
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
raise LauncherUpdateError(f"launcher release contains a special file: {item}")
metadata_path = release / "release.json"
try:
metadata = json.loads(
metadata_path.read_text(encoding="utf-8"),
object_pairs_hook=_unique_json_object,
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc
if not isinstance(metadata, dict) or metadata.get("schema") != 1:
raise LauncherUpdateError("launcher release metadata has an unsupported schema")
version = metadata.get("version")
if not isinstance(version, str):
raise LauncherUpdateError("launcher release metadata has no version")
_parse_version(version, "launcher release version")
for required in (
Path("ptlinux/__init__.py"),
Path("ptlinux/__main__.py"),
Path("ptlinux/cli.py"),
Path("ptlinux/self_update.py"),
Path("ptlinux") / PUBLIC_KEY_FILENAME,
Path("ptdiag/__init__.py"),
Path("ptdiag/assbin.py"),
):
if not (release / required).is_file():
raise LauncherUpdateError(f"launcher release is missing {required}")
return version
def _self_check_release(release: Path, expected_version: str) -> None:
check = (
"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)"
)
try:
result = subprocess.run(
[sys.executable, "-I", "-c", check, str(release), expected_version],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise LauncherUpdateError(f"could not test the staged launcher: {exc}") from exc
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise LauncherUpdateError(
"staged launcher self-check failed" + (f": {detail}" if detail else "")
)
def _activate_release(paths: AppPaths, release: Path) -> Path | None:
if paths.app_home.is_symlink():
raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}")
paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755)
paths.app_home.chmod(0o755)
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
if paths.launcher_current.exists() and not paths.launcher_current.is_symlink():
raise LauncherUpdateError(
f"launcher current path is not a symbolic link: {paths.launcher_current}"
)
if paths.launcher_previous.exists() and not paths.launcher_previous.is_symlink():
raise LauncherUpdateError(
f"launcher previous path is not a symbolic link: {paths.launcher_previous}"
)
if current is not None and current != release:
_validate_release_directory(current)
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases)
_replace_release_link(paths.launcher_current, release, paths.launcher_releases)
_fsync_directory(paths.app_home)
return current
def _replace_release_link(link: Path, release: Path, releases: Path) -> None:
resolved_release = release.resolve()
resolved_releases = releases.resolve()
if resolved_release.parent != resolved_releases:
raise LauncherUpdateError(f"release is outside the launcher release directory: {release}")
_validate_release_directory(resolved_release)
temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp")
temporary.unlink(missing_ok=True)
try:
temporary.symlink_to(Path("releases") / resolved_release.name)
os.replace(temporary, link)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def _release_from_link(link: Path, releases: Path) -> Path | None:
if not link.is_symlink():
if link.exists():
raise LauncherUpdateError(f"launcher release link is not symbolic: {link}")
return None
try:
target_text = os.readlink(link)
except OSError as exc:
raise LauncherUpdateError(f"cannot read launcher release link {link}: {exc}") from exc
target = (link.parent / target_text).resolve()
root = releases.resolve()
if target.parent != root:
raise LauncherUpdateError(f"launcher release link escapes its release directory: {link}")
if not target.is_dir() or target.is_symlink():
raise LauncherUpdateError(f"launcher release link is broken: {link}")
return target
def _archive_matches(path: Path, manifest: UpdateManifest) -> bool:
try:
return (
path.stat().st_size == manifest.archive_size
and _sha256_file(path) == manifest.archive_sha256
)
except OSError:
return False
def _tree_digest(root: Path) -> str:
digest = hashlib.sha256()
for item in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()):
relative = item.relative_to(root).as_posix().encode("utf-8")
if item.is_symlink():
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
if item.is_dir():
digest.update(b"D\x00" + relative + b"\x00")
elif item.is_file():
digest.update(b"F\x00" + relative + b"\x00")
digest.update(str(item.stat().st_size).encode("ascii") + b"\x00")
with item.open("rb") as handle:
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
digest.update(chunk)
digest.update(b"\x00")
else:
raise LauncherUpdateError(f"launcher release contains a special file: {item}")
return digest.hexdigest()
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
def _signature_url(manifest_url: str) -> str:
parts = urlsplit(manifest_url)
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, ""))
def _require_https_url(value: str, label: str) -> None:
parts = urlsplit(value)
if (
parts.scheme.casefold() != "https"
or not parts.netloc
or parts.username is not None
or parts.password is not None
or parts.fragment
):
raise LauncherUpdateError(f"{label} must be an HTTPS URL without credentials")
def _parse_version(value: str, label: str) -> tuple[int, int, int]:
if _VERSION_PATTERN.fullmatch(value) is None:
raise LauncherUpdateError(f"{label} must use X.Y.Z format: {value!r}")
major, minor, patch = value.split(".")
return int(major), int(minor), int(patch)
def _required_string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise LauncherUpdateError(f"update manifest field {key!r} must be a string")
return item
def _required_integer(value: dict[str, object], key: str) -> int:
item = value.get(key)
if isinstance(item, bool) or not isinstance(item, int):
raise LauncherUpdateError(f"update manifest field {key!r} must be an integer")
return item
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key}")
result[key] = value
return result
def _content_length(value: str | None, label: str) -> int | None:
if value is None:
return None
try:
length = int(value)
except ValueError as exc:
raise LauncherUpdateError(f"{label} returned an invalid Content-Length") from exc
if length < 0:
raise LauncherUpdateError(f"{label} returned an invalid Content-Length")
return length
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", 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)
_fsync_directory(path.parent)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def update_check_to_dict(check: LauncherUpdateCheck) -> dict[str, object]:
"""Return an explicitly public JSON representation for CLI/UI callers."""
return asdict(check)

127
ptlinux/settings.py Normal file
View File

@@ -0,0 +1,127 @@
"""User-editable launcher settings with conservative defaults."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
import os
from pathlib import Path
from .paths import AppPaths
@dataclass(frozen=True)
class Settings:
width: int = 1600
height: int = 900
output_width: int = 1600
output_height: int = 900
refresh_rate: int = 60
fullscreen: bool = False
borderless: bool = True
scaler: str = "fit"
filter: str = "linear"
runtime: str = "auto"
proton_path: str | None = None
game_source: str | None = None
@property
def source_override(self) -> Path | None:
return Path(self.game_source).expanduser().resolve() if self.game_source else None
def load_settings(paths: AppPaths) -> Settings:
try:
value = json.loads(paths.settings_file.read_text(encoding="utf-8"))
except FileNotFoundError:
return Settings()
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"settings file must contain an object: {paths.settings_file}")
settings = Settings(
width=_integer(value, "width", 1600),
height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600),
output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False),
borderless=_boolean(value, "borderless", True),
scaler=_string(value, "scaler", "fit"),
filter=_string(value, "filter", "linear"),
runtime=_string(value, "runtime", "auto"),
proton_path=_optional_string(value, "proton_path"),
game_source=_optional_string(value, "game_source"),
)
_validate(settings)
return settings
def save_settings(paths: AppPaths, settings: Settings) -> None:
_validate(settings)
paths.config.mkdir(parents=True, exist_ok=True)
temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.tmp"
)
temporary.write_text(
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(paths.settings_file)
def ensure_settings(paths: AppPaths) -> Settings:
if paths.settings_file.exists():
return load_settings(paths)
settings = Settings()
save_settings(paths, settings)
return settings
def _validate(settings: Settings) -> None:
for name, value in (
("width", settings.width),
("height", settings.height),
("output_width", settings.output_width),
("output_height", settings.output_height),
):
if value < 640 or value > 16384:
raise ValueError(f"{name} must be between 640 and 16384")
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
raise ValueError("refresh_rate must be between 24 and 1000")
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
raise ValueError(f"unsupported Gamescope scaler: {settings.scaler}")
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
raise ValueError(f"unsupported Gamescope filter: {settings.filter}")
if settings.runtime not in {"auto", "proton7", "umu"}:
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
def _integer(value: dict[str, object], key: str, default: int) -> int:
item = value.get(key, default)
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError(f"{key} must be an integer")
return item
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
item = value.get(key, default)
if not isinstance(item, bool):
raise ValueError(f"{key} must be a boolean")
return item
def _string(value: dict[str, object], key: str, default: str) -> str:
item = value.get(key, default)
if not isinstance(item, str) or not item:
raise ValueError(f"{key} must be a non-empty string")
return item
def _optional_string(value: dict[str, object], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
if not isinstance(item, str) or not item:
raise ValueError(f"{key} must be null or a non-empty string")
return item

1753
ptlinux/ui.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
"""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()

10
pyproject.toml Normal file
View File

@@ -0,0 +1,10 @@
[project]
name = "ptdiag"
version = "0.5.1"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
ptdiag = "ptdiag.cli:main"
pristontale-eu = "ptlinux.cli:main"