From 5a0e6520f440f81affc41f41f1c16ffcdc7feefc Mon Sep 17 00:00:00 2001 From: m4ko Date: Thu, 27 Aug 2026 17:50:36 +0200 Subject: [PATCH] Init --- .gitignore | 10 + RELEASE_README.md | 72 + install.sh | 308 ++++ packaging/eu.pristontale.PTLinux.desktop | 10 + packaging/eu.pristontale.PTLinux.png | Bin 0 -> 66392 bytes packaging/install-dependencies.sh | 411 +++++ packaging/pristontale-eu | 41 + ptdiag/__init__.py | 28 + ptdiag/__main__.py | 6 + ptdiag/assbin.py | 1081 +++++++++++++ ptdiag/cli.py | 203 +++ ptdiag/config.py | 162 ++ ptdiag/experiment.py | 1812 ++++++++++++++++++++++ ptdiag/filesystem.py | 59 + ptdiag/ini.py | 112 ++ ptdiag/logs.py | 288 ++++ ptdiag/performance.py | 278 ++++ ptdiag/prefix.py | 65 + ptdiag/process.py | 105 ++ ptdiag/proton_compat.py | 32 + ptdiag/registry.py | 100 ++ ptdiag/result.py | 71 + ptdiag/runner.py | 164 ++ ptdiag/testcase.py | 331 ++++ ptdiag/trace.py | 242 +++ ptlinux/__init__.py | 7 + ptlinux/__main__.py | 4 + ptlinux/cli.py | 651 ++++++++ ptlinux/command.py | 212 +++ ptlinux/depot.py | 1091 +++++++++++++ ptlinux/desktop_entry.py | 108 ++ ptlinux/doctor.py | 184 +++ ptlinux/download.py | 210 +++ ptlinux/gamebox.py | 27 + ptlinux/gamebox_runtime.py | 315 ++++ ptlinux/importer.py | 332 ++++ ptlinux/install_status.py | 92 ++ ptlinux/launcher-update-public.pem | 11 + ptlinux/launcher.py | 322 ++++ ptlinux/launcher_wrapper.py | 113 ++ ptlinux/locking.py | 42 + ptlinux/news.py | 354 +++++ ptlinux/overlay.py | 418 +++++ ptlinux/paths.py | 219 +++ ptlinux/proton_compat.py | 31 + ptlinux/runtime.py | 390 +++++ ptlinux/self_update.py | 857 ++++++++++ ptlinux/settings.py | 127 ++ ptlinux/ui.py | 1753 +++++++++++++++++++++ ptlinux/v21_texture_reclaim.py | 156 ++ pyproject.toml | 10 + 51 files changed, 14027 insertions(+) create mode 100644 .gitignore create mode 100644 RELEASE_README.md create mode 100755 install.sh create mode 100644 packaging/eu.pristontale.PTLinux.desktop create mode 100644 packaging/eu.pristontale.PTLinux.png create mode 100755 packaging/install-dependencies.sh create mode 100755 packaging/pristontale-eu create mode 100644 ptdiag/__init__.py create mode 100644 ptdiag/__main__.py create mode 100644 ptdiag/assbin.py create mode 100644 ptdiag/cli.py create mode 100644 ptdiag/config.py create mode 100644 ptdiag/experiment.py create mode 100644 ptdiag/filesystem.py create mode 100644 ptdiag/ini.py create mode 100644 ptdiag/logs.py create mode 100644 ptdiag/performance.py create mode 100644 ptdiag/prefix.py create mode 100644 ptdiag/process.py create mode 100644 ptdiag/proton_compat.py create mode 100644 ptdiag/registry.py create mode 100644 ptdiag/result.py create mode 100644 ptdiag/runner.py create mode 100644 ptdiag/testcase.py create mode 100644 ptdiag/trace.py create mode 100644 ptlinux/__init__.py create mode 100644 ptlinux/__main__.py create mode 100644 ptlinux/cli.py create mode 100644 ptlinux/command.py create mode 100644 ptlinux/depot.py create mode 100644 ptlinux/desktop_entry.py create mode 100644 ptlinux/doctor.py create mode 100644 ptlinux/download.py create mode 100644 ptlinux/gamebox.py create mode 100644 ptlinux/gamebox_runtime.py create mode 100644 ptlinux/importer.py create mode 100644 ptlinux/install_status.py create mode 100644 ptlinux/launcher-update-public.pem create mode 100644 ptlinux/launcher.py create mode 100644 ptlinux/launcher_wrapper.py create mode 100644 ptlinux/locking.py create mode 100644 ptlinux/news.py create mode 100644 ptlinux/overlay.py create mode 100644 ptlinux/paths.py create mode 100644 ptlinux/proton_compat.py create mode 100644 ptlinux/runtime.py create mode 100644 ptlinux/self_update.py create mode 100644 ptlinux/settings.py create mode 100644 ptlinux/ui.py create mode 100644 ptlinux/v21_texture_reclaim.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..051801d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.idea/ +dist/ +release-keys/ +config/local.toml +results/* +!results/.gitkeep diff --git a/RELEASE_README.md b/RELEASE_README.md new file mode 100644 index 0000000..03f628e --- /dev/null +++ b/RELEASE_README.md @@ -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. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..39f8cfe --- /dev/null +++ b/install.sh @@ -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 diff --git a/packaging/eu.pristontale.PTLinux.desktop b/packaging/eu.pristontale.PTLinux.desktop new file mode 100644 index 0000000..0d0c104 --- /dev/null +++ b/packaging/eu.pristontale.PTLinux.desktop @@ -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 diff --git a/packaging/eu.pristontale.PTLinux.png b/packaging/eu.pristontale.PTLinux.png new file mode 100644 index 0000000000000000000000000000000000000000..edb59e748429fdda235194efed53eba38fe381ba GIT binary patch literal 66392 zcmeFa1z40@yEl%CfCx%SNJw|gkV7gdjSPyk48s6}4BaUmQqql*8xfFhknV2j2I+42 zkJ}yY{qFai<2mR1U*C6K&IL2WdhS@y@4nX^>v?AIf1>yp8}mLU3JMCgjI;z8`5KD+ zO~OD!K2OSfwjy6{nMlI0F53W~%__)|4&H3fMAs2PGCqHm@LW4A|` zBdJkPgdWhMnEE-70zkoB9A*TUcC>`4I4VAcI=+PR z>r+1z!4$F=Kng&>tRa;42oqB)0efNUU*!rQ-+vwEpr-tl#QLQ$wdhxclxhl3D8(9 z1i%tfzo|q1B}{E(ZEY^V!C_}-$8N{PZf0r70p#cB=KyeWaB{LCDcGzWOsygIY^GK; z--P@wM*?OAwS=2n!_7=7zsiN^nb}wiQ&WFc^vC13^Fo;ap~%$gH|&roa@a%6Ie_c{ zj(=j(hyKB1ZewZk>(un29553Y0%mG$h2#VNBcHjEnYEdfk=egd`N!dZs~Bk$1%*F! z|KofSh<{XVWi4rogy1(w|B=(`se?I;0}Qh=v$2H2ByEwWLi0~&Z)FXJ{b`;5f+!^U zKMJyj8~htNUyuA-5McK3f5Z0m$TzlMed?R3g^-{Lh+D!S)@GJZ&CE& zSt20TB5(x65XNC{YAD3<=iqmgf4HZBxS5HWC30FY5iTK)e;WQrE(tTJ&DZrIY6^u} zA&p`6osp2H`ZWTGK@9&o`uhPBxCls83dk!C662TT<`w0a;^zbbc*LZ5B)NG&+@b(+ z9wCmeYaL1Wr}p1G;J0ypDJg&iFFzMAuLOV}z%9-z&dDPV-~@>Sf#Q;!+`#`-z_$pf zZw7_y3;cQ7d{90Z{ZAQ(3fn;rmSz{ba~$7=vF(B}ohV86Qh zU)ldO!Ed5}*NznZ&nW8iaPz^qA$)8=fIdG`2R8tD&QNYPeqKFZUH}NB&#%w-yQ1%e z{fiU2=DELyZ2<`M zYbq0gehp$UeIbs&jQ%;TKRAWH5y1b|oc}cl{)PSD>##F|nf^yX@SCV#ss6nXD>DOY zJBTGr)DRgS{v|$ha0xfsUb{XgyZ+q`hDQ<)A_&IY&;+XAP@uru|a^mP&Qs3ZU`H{o<0{F zKOdg~2x(G&7!NWsf5ZB}VK&~UKz;!LrvT4Svwba9|A={JrnWH4ABlR%*a|^*C@^Gw zVPI*7poEy4o4}!vuMw2P)>Qwu6913%O=)dL`CaAn&3@m>`2W#G^TGI&GuibEC1Wk^j|xv|Ai#@4~^nCsejF#e~%ddYkBZncKmB?`Fp+kueIp! zh4;6hBK0*Ce(i()^U@PpRUkWdf$tW}AI-pjF$MmO{V#z0CSCuxX7Jzk0}ud@9uF^a zT^sNrTLBOsvRLOwHb-py5Pld0$O8ay@Jh>!8a}cAm&ePzuQ6L zPq@G90EE77Xb2#C3S@ozYbFvwHkvrh*zf$#YTtt8|B>fEPwD@N_-{ggD)}LBf5P<>t{+0+hlqcA*H5^92!S6W{^?ym z;rby2eu(&|cm0IxhYxU5dA>yCj^%JfiLg0spe|pzXxPAzMA0qzgT|eRaAq0Mi_@{UMgzJY8_#xt--t`l% zA41@Vh<|$5Pq=;vfgd9N>0LkJ`XK~>{e5cnbDpWgKot{+0+hlqcA*H5^9 z2!S6W{^?ym;rby2eu(%#^e)Ulzd;0Miu~FTJLLC+P-@gtBfndO5-JT=KtXY&LqU1v ziGp%+g?wE_L9qd#pnTCqK@o^SK_M~=d(k9?f>L8BBO&_K{&Q8eU4;JFv!ye^Wi3(> zu6{!YvxQDJqLX*w5E?lMw^ITRB&gI-> zm~J&lbB`n5FLHWPQp(^7jqn`w-KWH8kiO$dr5=Ur^`=*2-hmw~+s}W(1WU zcxs<3_ily`{@x~k20zoE=yASmVJ8Na*Ba;@kiIaY-hvgaAv9Bo4Tp|N-8fe#_5?j| z42&r!15SLCae8%1O!q15`reUS_%*7U-O{zrZTxAQP|duo0TNobBR*ETsHpffI6dn) zZ!Ut1pG&k{W47PhVGaawweTD|xC36QG_nIw@j1mFuKiVPKmY*nUZ zNoiGkakp*1rid>I#K)qU14*vcR(88}kJw#Iyg8rR%{yK!XP1c!%V25}yVhbsjCI|o z;T2=RM}*vH30=`JDA==4idKd?B1}vpjJ3my0E5YE-{tvPOWjYRxKAA zFIG3QXo;JqPn;p-=IRZ>fo8c4MF&#WD%b8m&a~fcM7v2;51v4g=M~Tg+$O1x3B_uD^%S$kO7u~&R-+9fg;9!GCVyeRf)_#%u1}&I zMvd?lVqUjM2|Z7XWphZYUrCGp9uurS?gM!-H@8R4UXXF72Pr8i;n2 zm?jkbE=>%>Z!QAV)(Tb#(tYW&i0ac&VJwqZ3IbY3zxWV=oEcEzC)9x$vS5XJeK7&ns%EKB8$L(DeNiIJ7Y8jVS! zp5ave1T#xkBU3dFju?-6A2RF{4jn2QBHumEzx{z%;OdE<`746fngqt0fM~r-_vEWK z4$WKjYI~$x$@34Z5e$6f#1K;lzpxn5JdWm!=C&d!!#I$lM6oKc_zm-G7PcZZ*jVVo zvSLEo3aEjYIQzM>?A&KUV$T=s78h(I{Tusmvm#~AhpxrLSDZ(~7VB<1>(C;q04Q?Z zBrRj6^2jo!vU+cfBE5rK3e#UYi~yBWj?0Tc48sfPAXdOeHAnkK)*loMepk%&g}cP1 zhbOkbWPU8mefzdjLdGO{LDBG~@7yO8+-;4S#7EcsYB|C3!S4O*S6Gy{dq2IvIKCjl zmkth-@L!6awsU`R?!H!bP@$g7dL&AU>hb0j10JRq_)b6re+ukL>4z&zLPr+`HbOiS zjWX8KbSFSn_9!B_-mv)kcGY3Lj$G!z{H&7OwM7l$@EcrImV-22k1#q%Jlu6KC`W8A zo32?CG*K&Xk&TP#+)UJ1ejHsytMA7;ps!9J0VYoMwq%9KP|eB91rn-fct?QCO4!K9 z`_-e4Wzuegb?X`NN?_TJIiDlJU#zUMY1^Jp9AE#~KrD5sL)m$K8mmeS)7_G_A1mVN zaWG{dzK?QX4nZmnonwO^rRavcTNsE-GzSbNS_yT> zZt8SCLO!a`$OI=dZZRE=G>AUFq#FYz6MTBC;W=MTu#g9stAdvJ1$!Jpr z0yWlXI|hx4Cj@L!hS~gmRO{&10 zoU1U@CR~kt1 zGCgRfgMXP-!^2TBkW5R+?<;@}=+Gb$kJ;VVI!h z0?V3oh{}4y)K>=m0J1XugN#gl^*G*4L*Q#YR<}GcyxtCxK4Cf$I%-t077$iujqZ&_ z)4b*b>V`Z(QjR0P(uv3-HsMD_Dx8cSYgfr9E!|8fB zlV~vYV{=U6u_SSP)^Vb!xtlXr#o)?Y*fYMzQy9C@8R}n8+Puv&H}@9Rd|R{#itW*isq7y<>k+OY&o`O zoESyGhNtmYjBla3+D3b4NYr}pATh=lTW*_j=a)T`O-ri<7G%!@gNd5Vafsng5iIw} zTIgl8+jL7H}F^q!Fk5!*DG$thq^XnW_J(u|@ci$@n2xVhJ0nIpQ zJGXP(^O#PpnI!D^?Z83*(Bv;mKsW1_FZYM z2T;oxS-YJGSIHT|m=6TVGaggaY`vk+YAxs_PP~TaaKL!q`V7l6Mm4*qQSGKfvG$t` zhvpq;qDTW&<-lPyrrHvb-rLs(H@IuRpc|^oBJ{7f9JKZ?WH&#GY7c1x`S(fTqq=Vk z-|Z-{FuB<*9Y`qc^->UwK@$ki}=}!B@xoD$$EBBT9xxlYlP6VD@;1V2+NF>B1N5pDWtP zgv%Kz9Bb;zy+zPVM@9v-byJKiwvw(NZH{+@FK6No6B9Ips(TmfC;Ep~mf6pZO9xJ# z-F96ia@!iS3keR&t-h!uhdZsPftgvy>v~%XA}q+_J0C^CBhsb&3=^TS*S?kY)w780 zNu7sxljLuAfRQyCka+)Dj;icDh+ctfRX}#pFzSg#7)DD;8n4EftkI%IP=Rj`dZjyp_xSah{q<$xlf9qw(DeEM_Phi z`Z>Dwlms1JtyOcuIlC)a8J9B+%POBLHa^REJjFBoOi;I8%vMu0!KIu4RuL=af@UyC zbzb-yE+D~t?P?U0bFWiy%8_*IthLa-ImWUtos@R&#tWgY{KB=RqH3r2qV*XaR& zy^hJl%rUi0a1NL?N~Sh|RbR#*PfShLpH^X83%#X4ef0xN?My7VuE%2*rzhKwHW)_W z4VI1Q2DjfV<_**{G=&{U9oX)(iAGG7LQCc*ZFD2nY!$aZ9oVpQXz|us4&K{xvvyxr z)alf*ZDC6Re}QWHJ9Op5Q-&9S54!qgHJuOJ91!YYYajpPHJarje-$85Bg(AWtaMn0 z^6GH(o~S{=HNRl*DzglIc7)f`s;UXfUjAba=J=|mVUsrSWlbI?lun2Mv)HlcJvnI~_w_L=jc+M7pmNg5Gn z6ps57m2QWV$u5%vEzcjCcf4y0so6U-)2ed*QgGiZ>&pQ(TYI}&iFZAoMO+-KOx6f9 zvZ6)}1IdbF)C1`H?oW4#f~1HMSXqjq3pl((_u+DlBR3>!LFXpsTG6V7$dYn5Z2TO* zr*DOoWo*2_R%-l#+<%FcTHMi%P$yNDW z(q<1qn!nU+4(E`4=4)~%NruGi#6J*exVoGWQi6`*7DXjDMnJ+zFBZjHj zZs;DB$RM+DVh@=%_uZPg`#^qX?6*ofCN7drXA9{*Wv*9uOh+pf*2k+OE!&orME2pq zLUzL_b8aq6*GsoZp#w1;+JfX$hjHR+16s+&EuFfKf$XRSd%aejq4}|77XBW2b;qK$DE6uN%R;^CC)N|akpPnXLwCc&Z^{V!m zdWr90O*>02i@)2|%(ctYyE<+DR!4h5B$$I)$ZC@gFjG#WOQ(h_Hq=_yT7F!enUSVZAHXYr(^RlIz z@6M6qY~J9P^b0emA^MtS>@(u&!bsnTN8DHQcS9^3^OYV-7<^vQ>%*_K3y$|bh*Hyf zISVo^dD%f<4l~fA-KAvU&;d3Z0d@9Pu<@&*1+s+R^07uvl2So-$R+3I~SPVn;~aC(%b8WW_p|P)5{lwYI^xvh)%1v-tp|= zn`zIU)tpG}xWA}$r%^&IWx8#x5BCVUoIsO7{l^R4=?e^|qh|NHpv9)dQpiT=O`59jMt zaPe!a9_J{#%XKGv1LgT~2NvD4VSz zIutc{YiB|#-ZndE*+Dw8@K!hb0e|7>W8wy*uJjdk>$6)SB6i;8-jbUYW(h$x^jN25 zyh2X-ZP7_09VTNynS{8J>UA`WjU@FwLAO&TjcPwr<|eoEYL)Y`<>x1LeCu;@E~)38 z@rI0kv#msmN7EBuzL+_zmlxyv+73=XVFxV};6|lC4HF%++B5h7Gz!8(B*N1n%LR+hL`)K9=t8HaL;u;a-kY+0l^GNquJbUf08u#~&Wg2m(~u%)649FeBs2 zt9o&&b?!c`Z!6qurj5k`w?+;HU%n1;4p_^pmU=2%L!3hhvMO)ztu=QC0Of;+%*jFu zmRrZVpn(yu_bbIsH2pmcmA8dG7-<6WB8r$Iw6ut5EWDS;G-Bir&xAE5b4k2Na)zH+ zSKM$E)g4BE*ZUdfU}R-b+~GeI0vV%YjkKH&8qm@Rwm%&VD~C@9qINEaB;eeZ%Qi_Q zL1r%=s8$PMvhxCglGc}<#9B9soVso_=Z0C%+Kb>xYO8kVnVkdAyX4io?`+Ms@%c8g zDM%jVd?3KgmE~gVAB>TIDNv2lQ_?7%^NK)h->E^KxmU&q=)>Ak!+w!Ww7J!-Ky$TD z1<$~Ky(SztN!8W;s?9Md)*siTrTIw9xhGwXRW}f~D=2FH@N{nGa?*G$!~ggVgOa&L z)>&@+oh=tT9C|xp2gk(DM;yHgZmzN6MB@=_Fg&&uEIhAPb5gvsu0CyPq1GzIZP#$Va+gge4ohZ{?0Okl=OFFVElW8S6zL3R4YI zbnt4H=NaQ;ckV+|d3;30qOeKw#-F-G@5CW1C+W_?+hrwm-5ftBI4o!ow0ryTBR0{4Ax$;cy^WJB+ozs2*JvCx9J1 z-%Edz^9wo}FY-<8EJatZX-{8rx*ji2wJ;283YCU! zPfXCAzL{Md3EIAqH`#V^_EN~YqG^EWvyfGWXa=c#aCFczNY5c1{cUqcR4hq+hL+4| z4Xhr^k-TPq6gMAHp}SdSQJEDI^@Q_YCI_FosSTAvTR)bGH_XArT7dgA=}LA2Z9xPh z?|wr}ilIe=J7$P*Wqnfs@8q~y6bF&OR1Elm%1K6i8cQ@9_RH`V~55;Iz z3riaW&hZ=~t~WJhJQ&3dZD5GU&af)aaG9d5#t_y^`E zoJln?!8a_(s6Se0)wx&Wvv!ukkFFQtc)dfD`)D&^kOHP8;Hhu?__1Hl6cU-ELUFJY zr(BQQA%~47Bj0CZzle?RyyoI`GSMg5orC4DX&D_JeSh%1k(Q=ELbI9Q-pG*Idr&IVqzGM0-8-_F zqS|!pCFa}dvyS-}$yoc5pEOihBS))U2zRb4-IkRMF;vvT?BQ7D+piyZxcenq$4w5A z_UVPo7p8#RrjAX4)_SpU#q5UeRc7U|z6U;J@oQF&baVHjx=J@5z@~?(qo1FW2@es) z5_SfL^llyXmJl=;y$XlKZ6x^(u(sfoG)VVR%YY*yq@m{NC@?CUsdDU8)I~=z&BWQvU+&2mqwaOE zs6}ePd)JZ4BGg>9lciJUD@3BF12b%;THlaIS>q#~!?yD}Frc2lqhMy$v^nrGq>L(8cfP zk)5_jHA|VUu?r+r>uWq} zVkBN?CCYG|+oYyEqj{@jOXZ8v=W3j3kMZ2;&cSumc_@j13@9$`1FyKqUMFVF@?6xH z2?ukJA)WTfA@-c%P~Rk>F4@Y*-143M#+sdq&qpim;vx-vZA*J*s|Pysil#01=cM{9 z5=$RU&Dy9AYj&$ur}%h2DvH;}lw`0Wi?{5fYUd6Lq!$#vplEu<8ze@(cSL&BSH}y2iGA?kM~ffSw?jN)prq$Of|U+jxSS(T;Etz z?%BD$IB|26HoVqswZ4A!MC3gBMx}x4Qczw^EX&@@53IcW)*a4&+F!HCQH@Z-TRKIaw2$!_jyS zZo#U!QG$5?S~ll{HtaTn6jC%hw}=*gz{yAcxoRzIe$Ne;e#yyf{Kfem>ljXIDlAh( z!h2fG~kCk`sS&ZtH-@}u@6O-nLS$iy$>iuy<`GYP3T*gC!E)) z6cgDk=jw&_CcVgSvTaVifaYg9Ea@q+a-SBRy(!=6RV|0vdf_uf=d)m%G)$ZejI57! z9t$^pHlz?f%CzXuyQ<{dI7=Qc9fT2N*jgcGj(l$2=Kze2Fkq`oxg9)Ki-Rk(I7DD3 zwG}n*3Wf5*qgMUjXw*)U$JAb~E2D{~k;z@mz1$Q&rU}Qt=K&F8z+5@F5p|ypS=1G4 zJu+nK5blz%?&PRO+>H$D>Z#h|&T;LmLExLj{(_r%4MQPDAL(71=8xhM;AY^kRsfdD zu8yzwmNfli?Rr)ZF|E z%W66AEdfFMGh1yd{JIy;30b)Ds(dM&23$PCQsr8WssV49)$SBkvKdEW8p#wUf>|T8 zp34zFr!*%46%Z2DT660^*77lzN5u2`%Q`*>kA2F!nQ}bmdm!L8mcx(w?ZErkE+yO zctfj=42<{h`ZRn>Vq9IAF?iFRch}rx#|HB)kp(5DKd}$4Ok9eM4-ZyM+*lyA@&m%U zlPJ03ksEPJ#BmT`)vk|Sl$^+G7V^#Njr&Q$HvSBXohs~%C5jr#QQq|G@x8xm^Vfum3~x@on8}io1DmntfP1Jzyc;! z%BH4cszRP`p%rb}2HP@-HV{l(ko9TC@gfnpXMM^PhLwsvNi`_NjUQepAIMfv)DO9| zL{GhkKOm^CLmKK6hpw|DOHaR+AZXk*1ebw-{^Lv~R;r)TFJrY%T$QCt1@?TmjKeXphc9#)YQCkTCkmiebowROy(B zOT#4B1FVL$r7Y=9GPz^N;?x`>u0A6ERIhd8;rmidA!OBl>9iXnpd%;=DbtG^-Xi=Fi-~NH2|L=Df;YbFmojaNG)a-N@sxY{9N9JIX+? z=5d!5R4B%!kG50t#Bevgxi90hER9+7c81tXk+A}k4g%{HrAKQaB)I*A{XSNhar&nL z1jy|cI6Tyt*k^|S`nauhQFF0L5wCs_d-mRKd9$hZ%o_9Sl`B@4>*}V)A5^lQIvn;3 zbp%l;Av=4IM!}u z3mA@n^IW5Dkp8`%O>tK~K2h1~srH1+T5i1|1^Y2JGlHS>v+3m-?+B$n6O4_wxa>BBDX%f!OoUT>HTg&U0=pk+~ii2^p22B8-7~d zD-eZVtd~Km906&D)IEF}iWM&%K1w<6-1+b{rfhOG#O*sKIdk?mW$BWGq(7E@)+MG- zllti9HrtH8tN{fffu!$owDHzl94-E!1m(FPF&8jhXpfkWt*SY^Av}( zwPIdw7YOEFyLPqa8AAQaF{jbd@KK!pd21=zLXEhMh^@YRDv`kYm`E@kK6@vJXRjJq zR~_@3eX#dD<_GC{ezeDQSQS*0u8)Xe(oDaM&?X^>b1IO1vmVXjI@!e265?dC#l|sy z(T2*p!!?3Zol2K;|9Pv21QM&hwKWAfL?hk2 z*O7M?od$P0Z+S0^lo^gUR@xzA^$@8Y?}Xz?atKe7w=S^M4TwpZ3Wo2e6b2@{j2}!m z>=Cs1TC#n(*G*cc#{(kIcf(~Uzn9q(4_(#z@7 zx5;T+Jh`W1xlB%0wnHbh(@JzQM^uB_E=o0814Z9Dz1*7ZKGSJ=a6YxQ+h1u0JJ^++ z8w`cvG|RBZ`cvOoAD4XhWU0Q1j#cw5tM8gw*_)+Pq3S8L;B1Tr)Eg16CHq;$GfBKt zl&VF<@EG>b$Z>oCZEB`P#5we2Q?)mlxHAbWX*Dd3Dm`^OKOAuOtsW@UF%?*yp=PgX z_aU%XjvTc#Ow4tcu}0QoYu4K4_qJ}-Y^)JJI+euetkF7@vM|V04_Fhp^<)|-c$m9( z#}ty@x42!mu|6iyeRa9rkWgMF)@G-WdJiKJwls1zsW6w0{=RDQIR#{3NdVnW@d@i0 zC6s=xX#yhlYD>^d^i?{lhT#fp>hxOS)XL16hvTy+hXjy_g0irJ(bjuj#d=bBw^?Dw zC7QZhmoa@=8-r4cy_*l_5`x=o|z@ z@335L5UHt|0xIEX*Jxws@RJ?=9)}2ZRJtAYoMe<%d1lsZ8xFQKlVpvkYv_n#Ht7;0 z_uD^0#|e`hHVI;R{p9<;Ln!B{`lq0@SiOvP;q1++3`u(iaeC%FeIntUOXiL^fBW}`vr z1Xn%D&T-*H!`s^Nq7k}9OQkheiZvIofu+xO&JtKmdvPwBw>;UabvhU9gFsm2mN0)- zxnEQl)bXT9qNA}%6%<&!R&mP=vS-=@X7@d2H{*p|cYMX7 z;&2t=1hD8GGT)$}Q0?(%hZA8wtABUhqg^il0qp2Hq#jr-Nb^9PF+@tX^yT#r}moQP?d2wxMw!=O>q9@m99 z1j*)0Bp8vw?f3ohwcHvGUUdOv{pMm;`%#zZGS;q}}H$IjX z$%ZZJm()G^2saM&5Y(@J8MsV4H*@G=RNTIFF4peNg53{-46GBg^%z<>C=`(*J||Kk zJJ5oU{myIk8p}66HII-z(&SUBCT7ky`C#S;Ak%-jA(5bi;Wpnp6>Fy$5;<1x5HO_b zdvtjK^@8ZN-+Y|bj6^zd+TPF3W#o>|;b911x}>w#VbW%>2) zc|*1dubmu%BO#sH%3?a%jg6jhk~n=fjge)JARR9$=dNvD(<$~fC~V7M8y~e}Y+(>% z#%#jNF+iU_I@w7+wwSuue7LY-=E#-oc052#1Lut=M#Xq-nbjQLleDnjP{p2yfPUgD_78`9T09>#)^8}veUms;i!ri;3bc>0>7?HabgAz3e(R`iD_+>n?j zEEUvUoF0Hh(#>`R-%ANdGbUW{!7Ty?b~1P z*)hoY@;qNcjG#$A`1#5m8g@40dJInNV2(1YyamH4jPPgnBM(F$fP`*NpJP&5t&|L} za_FRKWxWe}tD0@`rIRjL$c8M?dkv&dVjbsF(mj0Aj9%-l+e_1-JD%%OrW4CLKns_` zYr*mimA`#C&T&cPda~^1o_svJwOrBQo6j2UVHgub+)_ed=irO15UoBGpUJC-vGS;u z1ocrV3GHB+IS#BT2g@0vh%xndQhIgD=rmJ46T#fMxGsi~h8Gy3%JiI>R;GQo9a9vi z&&Y~TDUjzjn%F_WXGJHP9ffSAPh7A-d8 zo2L`=hF7wrI_TDyV@o6lrx-6bj#gIuM!AIzJO)dJr!SIT9?Exsvp($)f|L>ykUQ&#Fq1y$bhk^MMG|0tK-WKzgeiVFkkJR->? zNnkjbO-KKjMavj7F7e4k!4Nbpg7m4do2#Vr?p_Nlj}LG(Vf)A=o}5qVL8U~NhVDp- zf2I4z3punfi-heVSJK2UCySfru56B5Q`#z({oK$GV11X=rUs>ibk(9??0MI6UL|sg zB)jxf3mv{wsXBce+`4AB(XYvXxwM}yX`&QLhfCTaeA+;O0HZbdkqeeQc|ZySVP!?g z9oHalckbJ`11~}&o*~Wiojz4Pe)qhgq`_zTf%l7AjTl9>ZG}8MoR(0j(d%KqGz4oq zF+P8+u5Gr|uGm6VY3yJ*3-*0pGY2@DI z$i<^2M5u|VW*aAJ!*X|!AM<81W#C6dJTkT6pazC~HW?QZ%N}Qt!7mzav}kS|#6r(2 zcOP4|kYYIzx=RaxKQ_9e$IqU$HZQ;)mFOgaHjC3yVq{lY^m(%CrS`UkiDVee3*J1a z=AGZi$q=RD*l=xRlQmAZ{81`-{>OeEB_S)B4>`sAQ`%gOSxd)Rl3ZNc$o;*m{g=0> zU)o$`_voaj#JkS6-M-4wvYf86>%bb)z=gAj?3EizgJZ_P1~8}}4OZ>dJm0FJ(udb0 zSKBn}F^NlY8_?a=aH1zk-Y-z*Vjv)0NLJUoi$dgu2>MLgcs?bI)w%1D8n;@pBLyZy z#d6v;x3G@iJolC#alfTjjWK-^{}Irdx5Z}n>2UlNfKQ#javhkEINK|IYy7}}p8I*Q z{(UIwg{V$sRtLUSiU>Z$vQP^I&YEv|sm6ZM5(x zFbO7q*fpg+uBNHkqD(bz@Sc^ev^6)5WPP$SeCz6ROLUaoA_I86UiVSvxL$Q%{Xu{( zYP+p6UO%lqXr&N(rW%2l7Hfd)mlFhRC~_1`2_%ELL^(ykdZ|N;`?B@!ByuLnOy;I_ zJSWJ`rANe0IqHjKz_V$I3u!M%h)B&OBz~}?Zg!Jf5O?cxQ#gW8p&fntfGOikRQcIwlEMj!09PFF z5vBwDOSygI#5oWH>_x!J!82Ozg#LW#xqnJT{wW5_ONBrJd&TxHO(FU(RPtiA=+k>I zw;P>Z5qHTf5T>IT$1u3LKe_^UxsoYfLiFJu-C@40Jznlu?RNI4oe~xpwgZ zczM0^g#v~IX5Y%ip-Rl6wD9Pw%VufKHtsBg>@GR;9EV&I>@T^m*XiOsVLVO~m_J6| zTOPkAALJxIxW^jMC!yTTSF;(}Dc@s0drp#uLvUMX^NTUJ1wr^Nbl4d2Dy3I*zBViD zO*%=t?O26FR8SCooYoO6&=Vnu{KNqq_6q7JMe&4k`_a8L8btyBy$nNW(i^IbmT$NP{L8TQXKr91vQ9!$~9S z6QR{MaRQqYtw&y!fsQ^1lK={qBIf!h@Cpm@ky}Kq{ecD8EuIPqTQaux3N>d$vw2EO zBQ{q$$1^V;!3oOr?AJ#z?rN9&^11Gj$_xa$X0`+;j`N=m;--K&yb+r3BJ^#Wmj{wB zuY1;o0Kf+h7o-#mn`Xzns4fMxG=(ahxM7XghYfAp}4y zu@uI-d{!JnDY+CzssQ}M%>)<#!+t-X<|#9btV2vD8W4PI0Cl|W8CBHAJJ#Dl^%fn( zRLWori%rbzHS7|^q)YLa4ihGJaZ^e z)vq49K^2?pm@=~IFQFNF%j&Yf;+Kek55>MmUph^$<(|tOxF7a(4^G=I=b)>Puk5t> zD(-r>D9u;$LTPR;MOB!vu;O{x_?-`aEj4&4WU4OmCrWNb|o{RQgiA*tF z7YYd-jR@tvj5c0@>@K~pj+P1&Wo&8cFh0-8Jq`khMId}PERYhH%n?=NA|Lj<= zo~;11w6^X+HD3ldwvZvHM5ry3eX2X2!dW=HP!QyD4p*dj7(j=Qg_*;1Q%O(L2M}nX zjr=6XJbto6#JFjB)afy5Ly)9b0SQTb+Bm#VO??L6?F^XY^5xb!*uPjRK4r^lWkysn zL8xiBKnhcoKr#*PQ((f4IbNb1rEgK(a5)(dx2q63>ntRYlXUdF20Z|tUWC?Be#&R7 z61z6Sg5!f*Z`lXN&AfH9_S0Y^YL}`n%br|WaL_PG&6eI6p-k8eh}VME$nJEookC?k zRcKf1b=>s)!HCk%B?BjweD|o3Qx>Fs@p||?U3YiRM;n8PG5;^!CyR$YwWI6@ulq`C zoR^2Q5XC7ib&~_fxsnM+0X1?728=V4S0}u4(*7@;r-4AQ&1kiQM^I3%bQqP`Vv(}Lzh(XcPlh(@t2g%7UZfU;=x#JRfo9> zRp;B>Tc`Qd@wV5M8oY;5-FCJ`Olwxrw0f53V(~F%$_NvS&< zfkVZF(}zpZdoSF+Je)fbxou^b&l+F%vF8nkOBjpQvYNK~+@au>-qF+rnV@aLjWau- zu%LLgNV_>PjudXmhOTA+gcz5BB&#h5$k9_A`@%RM?ueHNA0FXV)6r=v|Du#tEVUL$ zt03Awfu=4)HeOd^GB`9-gm^8R?+0dn{wL#|r37ulYR{G$Z1R|jE8aBk^|FAGiBAIx;*%wt zfpIG6Q$t8n3x|S*`4%gLK$$%{AJNqA7;aoH!Lmk^{>g%J)ebbdN^KG?JK{0$k>c6O zK#zO@w8hrI5P1u#8II2lsV^$)V|FpDt6aRQsnduB^;V!X9X}VJu1Q*_@X-{~=x8i^ zYPRlHnIQ!=RTA&i3`S}i%(1g*7uC3pklPBAkDU1Mhd?b(0 zKV@tt?oOYzeAB9Y zoCwSU0b`_gu1^a#O`Q86FBfX{P?Ue{J?FPP`aCtlazkb$f6DQAW(mxJ3Fkg@aM<8a zi+$HeLFv^ZIE~yYcp%*3`yg(4X48UR%GnFGqkySQgs%2g8w%t}zaA(M@Hte91g&Y^ zIx)oclwb9}ix)4(H9=cb7%bg?7;QX%&sd64t0ncxHZd(>P&yX%@eB=J$yRlgrrD$t zf%m?XHt#U<8u=C)wffxayTbC-s-o(5q@>#=Nn66K9RY%Z{x5GwMG+#MHDesUTl(OX zD~xv0M{|oRSUh0y^U%R{cJw=BC{(eP_BSD|L!}xxUQ|iTa}MnBhM9hNeyM)ozUJcD z{&UO=$S0{n1!9Kh!Z-9yQ6rs5Xo~8jNuMA;dJqv77!(N5$X4EViJ0V#ZbF=W}>t@u5KQcV?tP7IGpCz7^n{QGl*2qQhuTQ%9fn5SaoL6Yb z9FB2yT(1&^tmN@pNYo9eygwDc69Mawu6-;Rrjy|*?xTVvfElAeE^{RJ*RqigEGF|h z-CL4E(9+8=S+J>d5VfTGb~-ESTdtrmk10y2v2s`WwQMk9s(*dxVlln!lC3*3ss_>r z<39>bCxPEjB_lG&;WV>vhSzUu3;r_9oWO5&r)SSRZ!UY5@{`5ATqrIUmlTU+`_0#i zr5%phkLxV_w)t%xK4Ci~Oqiie)%pNbU$EG}Rm8}|S>l5VS09%r^_ zUSZGyhnhK@4(+r`-3p0(NrK;e;HeODF-`(>tNU|>r=A}pFTz~4BrUH*Qk9ZTDAKbo zsm2XDYl2GZOL&@zz0C93+5i|2%6uuFLD#&Cm>Q|D}3 zrsCWQg)FnR0U$PGW?ab4p*435bF1pel>G&$V>gn&9a=eBexZSE*;0UZ6!EXx+HMK; z+?)kqIiUjWOCDkRg%fl{QPMS~_yWT+lO2=BR@LWdVTkIssGM-8)fdpp%94#V+pq<;p#;rqj505ir%T~`0dKwl=iU4N z05L(%zG$`rMjhBSB3WKe#ic77j8uFRB&kthBx*HJ4Eix;&!#sNZ%#7vYrqr#PfOB;^rH!<@DJr zY}-1CV>=9nDN&qY*|56WAQ%Qzs}t zXr0j=3>d3;bP~lwC)*s}T*x(eV5BXHQX?EoB8)W6_JA#0s%+mf4)85}6?VM;C-J6(kuuQQY?_%cMc*My z`#4G>h0T?v25Zd@aTw9*4rp~^TxDVVMWk1vRP<0HXCYda;PlzcJpRNny1gE+ea*ex zblpKZojz%n;W`d+oT8M)MpyHDfACS1<8kPQo2gYw1j7w_y*ABelT5?59b0(*)H0Xn zRBDhHr?)kSKN0Otxkt0jvnLe`OB=XHRyD@EG(~pHdLw= zR@NIVt*uh4sf*zf0t;uws{P!eogfJ_OZ zlrYJds8=~U7hp`xE!#^1Y`L*95RNTGw;M3*L}Fs1YLZNtitXXM_B()`UsC{0PgeI- za(fmkUXhjcF3nb(D9KyrZ+&1lChGBPAG?BWSzNzsnpP09*6GW15KCcO_ytMD6|Agv zNrk57OPZ|?=Px!nf4<3+&#g0>t?|;G8srF-krAq8pK7^4!MC|Gzr+{5aD;`0W$wM} zW^TFp5Y2XnBun#8Vwg~`*BB&{Km5bLq&hOe)a)#d(zuRdYWb(r0}ovFzR%d0KY zOps|P`VJ!%MW;VteXW6#0>9|7u(-th!Ve}LY?TjajmB|pjLFEd1lLjczQmD`#n2yUhOwa04k;B% z2)n=|&uy@6!ee$O&qAJKnPxAf8-{v(w8VO&!|Fzhk!rycLLB<1wekNzmNJ+JCnl=5 z3&2(qEix`IZRE`{6(fb>bq8&}bgIR2D>esqObgF-=yjIy(vU$vrBL=6tB(+ynDw<) ziY~Z9uo0xhF(?URcEGnEhMTSrxHs+dsYgfXha(tEVyoQRu-WeL&=-%eecKjpxb9l~ zVu{C&JcrQWSQbH;F)=>IktY`TpTGS+e&pwVjuW5!Fza2Pxw&;3%?{Pt=+$Efw3dKP znrW&fhqaA>e!t7G*CGr;iUptL<#kS+I?u@H2<1wJy}Nhtrf+&3QrgTftgybm#-QKl z+2>Ai?D#3dIHuVgQYzZi>ovy5MsvzVuK}ij&I}TXtt_H2B27{}&m~JW#$+V%kb2o8 zjs`fTBA`gqeDR9!doA0S!T1gxGm~|7Ob}nuh|DTy$rZ9<+_UMft3@hhs8mX{y8+KW zcZNX_bNJ8!;=}uS?!bgGMi;8v;^b z36=Xt3r!deDV1x0j#I>OY%C!-^ZZj>Ja>gKHu%LN zR^6xF3@BupVJ5L17u$g2+Ki1?d2XS_-3Jp6ZL5ec9*azz->-Zn_J+X2Q`~Fb@?|B zoMR+eq%+V=)FhK5k~C;=_SAD+nLAIZpxC)>obfRqDKu%8v3=VVBcmlYZ60UXZ!tPr z<@AZ?x$cG=sgI22Rivt`=^)~v2i@`Jq1QkAFU##cwx_Lw>Lmp5`#g?e6gO+46b8iTX}yd(_a3Ap{m1kg4mcwn?rBux{P6hu)-s}rE?{Ndkp#|*s~K7BmjvoSk6#NQEPb%oWXi?}MG=qW}=D%^0(4IDnapHe{)4O%Dx zAO6^;a zh@+5gn>lA5d4vq5M1an|kksgKs!ylI9wO_2EsMViFe`D!CvOW>-E z{$PMaQ!jf+M;O|ewq_v{BtF~caM)6XclieV&Zb^6#cpi+=nwxrh^U`a{2RG{7N z(rUH2bm=lb_TxXr+@%XtOF6blGZ+RTwxvkY6xVZ^8n0tXL#83k@}02S7_Ke}CJgf7 zutu=5G|$q)96^|(wYnNW8q0D}%Hs0eB97}YTCcOTv`T+CL`p%ycS+-fW-mr4f$s=x z+b|gP@z4|)Vr6N8Fo~(wTs+@ntrepUR2+EV8aQwOKJ=IHn}3_}Z{NBHPkP+#X7AyhJowfJDO75tNlX?;L{Z-Uzxl40a_IVNIeq*LANtV8xpeUYPk!e8 zRNNN3_im=N8nAQE7A{>`=Li17k8u3V3I6j>{viM9$9{q9c9m)O3}YiM=jZ!eS!!WP z=!JbMg)&N7D9ffl2=F|QG)-ArX;3T{=?_92$HuX394DpUA5tp%xUPeeg41U&5d;yg zlP}6ekZas@-uLkfE}o;1nIaf;C{N4~MeSV9g9KuP0A(p^)e?_C{uKRQk4m}7mrb8_ znt?F%0&q===N73HW18)dMyHKiEMvO@q>YgoMr0&GLapeqAT8!LLay0jp@bof6J|yV zOjJE%v|y}MLXIiZ4Br_XE87?ca)5-HKb-hP0=Mb%jJ%5Y`wQjXvw^ z9S+@m8~^4VZ|B-;cJODv`&&Hx*)Q_N&pgDX3ky8;^b_pZTBlYm^FQDHJN(;sypy|c z-N)%mJ(QJWp_%al=jJ0CtpRIm%b0XXt!k5HDaB%e`e==@u^JoA4MuC_t2TMLGn%Co z{b5L!X>4VoBsAL{dc)z>jnx@xlCPTlvQKjyg$#oM7;qdPq)n39*lv+8?*LAs08DNX zcx8T>PPhMMO&(d6VmS)OQADYr+si1}64!BYT$?yfSzBvTEcukmKH(t1kRdFEkl;Fs z#ZJs145)ezNs^ID%xmty-c*XddFY7?rau_yYS{<47I@89bA;`*B->JLh~j)yu(r`> z7^FmThV5D$*j!?<(Wf!UX{hCLg|b)1N-^teE#fdm=>eTi2NX<}Jqos=)r(0JaDh8_ zLvIeW z=V$-xkCA4uvJsMLi+Zhyr6kW^=us>CL}8Ei#v0{^GAM|@BM-AXJc)ZC=4lA$57HjS{8*;jbgb@ zp)|(mOUC2TQdrke7@KloD| zzGff4@(VxA<%K?jASFs9J7&uagN&1xhA1T%4!T4^n5-R=vYlAE~l=W|U`7FJs84`YxWWD0nXIPE3|12$>P5f;83$V9O{> z?F4o~a}oaZi&y!wo8i7gCC<+${Ev74Ip;37c-yysGgF&(VmTFrv=PF|6${EjSr*Fj zP$ieh570=YT_%nCWM)8WB(}gv?iPYn{>S z4X9O0IZf8JXiGs9Ll`S2Cd!P~Mi`%{Qz_X*L5Fsy%V>Rsem_8^A^qNvFbv3-Jtruo zu9}}Ks+A%e8%+vcK9KcYi(=8E;5ppC)9}taZAgzm^##Vas&g48uUouXJ|+Gr-b66hI1i zb-{BG2K=H+GE9j>jm{}{OjQI$5wO_I7xk7(ieA#ERQ5@tSR^{b_7!Q?rym9=362tk zi9x3j8n|{d)VIU4e+@tI+wjH9S1-gbFuz>ly1g}Sy0*mUzwj`PR>H6R%1;x;5mA`r zyWEAua(wL47$h-q6e6UgQmyCuD%S^H$C#KwZ<*nsN1W0Zh9w!^3otpJK;7z_twnIeoeVb;TOHG_VF zi4DRrq+v>y#5k5)A&XtxJBlMqRmWX!Gg2=g^s2qR3dGcb-&RI5eOEJleGAvM7u$~Q7A zLl_&hhVc^Yor3@OkKv_1`FFu%1FIduV$b1*8xQiW-|=SN_rLxJKmC*cjzPbTZI|#0 z)f`WBTKwtz{)|g!FMwUbDU2YMAk!J1=i_-rkTy{aNm?SxYFO?xnJF;{73&)b(=)To zOc!|1d;WyK_=~^dyWjB^M(Z^?y_jJHQaM!14o@9l<8zOkrdF~LMA$0BcLc6up@c*s z(Z+ztS4A0P7zP2hEpZ)1e~_?cc7%zEDhP#T*<@)(FSbCMhb~QW82`k7JlC26G@g)EqcPXm0nowadC0Y6blaJvM+&`|6>?vm$Jpg zm4yb+omnEqU^x(mRP$O>dad5A(i==fNi*UFC`6tJJF=>+35#cluF{YUGBr8~GP;A1mBxVHFw|EUWNyD&ssCRCXu?L~ zTQ<|0NoS~u zGl}K6jMS=(jaE>O;t$^UH`GVVyzH)9X?F%RySWp_)Cg>!wK#QtgQridF*RN$%L3LL zt7JMw3P~KN^acTgL0-m7At@GpE-q|P^erkyhi)&T*BcUs1Ioo55TCuc#>7~K&9jr( zZiORHKF8wX3UL(Xq%xC{Br$OsW3t@UesX-2f}d}SOp=r!7?PwZZ~qhcz%iJvLKK4* z2FuCmA_Xa_6kROOLQ0L!G*K8MjKpzl262i`BPOdZ08x^XWSJ-xY+*|&Tw94SN<NaqT&1p#x(RO3)cR4n~XGuwGIRsylEeN@SX6}{{tqc!163^+3PWk6=4uD?DqkM?NzbeB0@+| zHp=p`Y>zmKD3)t{>)XGTYxd6a%wwP72j2BgKJ@t}iq24D)T&kLnyNb?IzoIRUQ<1Lk2#w1b1sAKrjC1?-G z7v-gbI1JEPitjlTJVg>DXl$g&0l$&??yxY@Jbt=E9OooK4P=%Oj;kcnAcPQpF|Ygp zA1MFd0>}d<%O#IA)m*&1O1nFxT=gkjwZM}|x`CuS%rjECnEUD^p`lXrDVK`~+r~(U zq5vZ@lqE^DA=Y^Y`q2mA13wRMe0we$X+00FiaB*^jmwu;Xf}H^R+o`VViigVp+FTt zSfo*(I1b1%4LW1*f$KSZ%|Tvz_Z@7Rs`6t${DVC7`OgxE1BU$`Q4|ozA(qUsM{<>0 z69ge)6j3ebgWka~X47oUd8Kpvjsflr1$3eO|B%^Qsd z@A=K&;_g?zjXUnV2b0BsV%YDIrWuZ325ks}G$*ZF4nitGbNSMFHcgKbMiFV6kfs?? zI3ySh2!h~hwUpsHiZ~8fS!>`q5QYK$KqEwpM~^(q;X`}5Blo52geF}AtO07uHGo%J=AyGn6@B~Uiq79Y>MHdDUv^qlu!$f388)1xa zEood=A*I|xmfa2<{XYY!XfV|zO>ulhq3EGpMZY&BPV&@N_9S)|Vw=2#FIGI7%^uVB z5lA#AFI+$+xt(2xwF0Nl!|!~wOV_M(|6OHvZfPKN%EH1LbC*^) zbAE}Fm%G$9-O3$z-$NP(AQVRAUD`C&bUQu%=)Hf!iQ^|3^oJhB%JUI;C24xYBA6gfZhIE*qWXs(wwe-q_&Dr=MrrmQ5_LHnAO> zYxZp>3AHC&eHF z%dzl0DLTE(XswN~6qaQfBeekT2Yw%jzaoGDUpkV}dX1IUHo+hwO0xVDpadmHva~Xw zJ52Fx3s-6eLBt>kx%-9#cs?|nYmAmXw9eNheOVKgnLG;x3EXre{Ndh^-~H%3@BY0- z%7uK2?7O+(FB1;m`_Av>t#A7l(kLd&GSW1`WCkTA&5cz$?JjTs)(6S5Z(@CAnK(%Z zqW~!+jr9hO>*9MJTIWDh3D822rV*~C>G%39ueTW~Ns>eor<(DyKuLqvn#GlM7FQZb z>Co;BX?G(k#WKBNg6mq`d;7Iy+U1$!bGVL2wd``~@&a0?c#c7537uZX{PH?cVz4bk zuhXE}(nLnF|G;(*@884H!UC7)mzkNFB1!T&Sr!fvBF}D8<_oprC}ubeuq}noG?pdv z`C6J$EczH*aCtTF1LdzZ7c5F4h;@d*kR~yf<$+PeIwg%FJjX{E$!aqr*{ZRv9G(L9J#p z%mn=?W2Qbr(RGQVjA0b9xX~ikx!fj>;+%>gK}(&}IWsu50YCbu@CzB-`aSR+QgZZC zjb=Y3j0AqsXJ&SWSG?*Ky!MT6MOiLU80FS-gC235US<9Hr0E9Xpo?Fukfsr9D@#;s zBLrd0px>t~z;&IRoTW1i2CZYNMVnJ+=Q(?EnVE4Pqiv3z=~FIPjE%TdtD_W42^*~* z#bS|tyLS-B3Ef_R=lRqsrTq9P6ety4rYDPZyIoGNY~)~HD2$Nw1B1>|(kx%Xx4Mxs zQntDGfxEfo`hDDX$6XwI=6RaU7O#HQ{j9Gwh{F(s$R#;OlO#j5$vuFAAY|s?F1Bu( zRxqGdneEDkYb0KQz5wl-o7tZ9C`hA7K*krNlNmA&y}8wsGbb zTMVKUfH=`?9xGESczo!I2B`wead3>yup98qsWX&Z#YClq5P9}78Hh}irTNcE3ZJ_S z-~1bbpY9s2nS_Z^m+f2Y7$d3IM|k-+yoT|aJv5dtQL0S=7I7314Ewa3O_b&0xCM%( zGD(sWh9RX&l}VuA?Gg05xUPd`Tcl}>ASF#AOctUDId^f9vlo|{s=JJo;mPNFBq>x1 zDKk?O%*<@2(d<*LhIp<^tJ6maL8(}z+3qt|AEPpo`z)S1bD3hPNT)X-j6#ggJtV_O z(@zpot!Z{*;@EKCy*F~*!JWMH6|bh(T;cuy`%f4QGhX??%WzzmCx%bs7d)~|<2Wus z5Mo&lI@2U+!o)$K%A2QG#f$&>*l4meK`BX+XdKsJ(9iP!C&jV_M#q>;VNj%Th|mGPlkoW`&(LW0IJm2hXQ!Mv z-=N)3DSDcs?_gOTD=Td}{eVR0G+so$G&mRrNM$iMzrvHxoFIx~Hf@?=eWOKVqfMG+ z2*?$xS3A<}Zp8G&D2ER2;*Q&I=E8+@{N{iEC9b{xCid+=L>LAvEw3Q18jV|4Zm=1} z5lSfpxmqKNQw|;2OB}^4F0X>gu$6^vSrmO2sU$BsG|GN6K)CRkb2&}e5)xY)QllYF z$Pxj<#`X+R5Yr0`x{#o=1lLvhAWP*bNC|`EDl?1&qfgF3+#h?DVjt$|bZ4mYX5RFEvS$lxnev77}9_l$N;CMp+7tCf0GjHqwU7 zWTY9i`mntWAN&#cmDd`40bR%Cu@ha^*W0*`pgulAIPBBhSR;u7`kf~I&N{)Mjb#Zu z-zCqG1F|GUNf>lntgp@y3_2uHKpYLREpTl?uf2+AMKoItzVO&dI{kn{yX#Dh+FYD( zF}F0tcfoVP^-3g}WEiLP`a#Y_2qVHMrr#UT?)Hh3l+))faq7%P8qGFIn&8;^f);^5 z2#FLjFCwCZ@v$QN_im@)`91f9Ea%t`g zXHK8Oc5RX*LD@F8<#6KkIhK}I**rT%oFt@KP9f>`2S`iuz)N;;>)}yu+yt@CH%STv zNP`g>mH=D1DC1Bol(1ZdQG!CXg6BFomZ0CyXf~6ah@=F&c2AjudpDy~DO#P_v^V+$ zgZRtTj;}bv9H5Al&LEDkQz$q#anaCh4bd2EDX983Rma8%L6RgGEyyB>(u6|UMHopI zW$0qQQ&bDE49HB9WSYe_@W$YW{~O$N2!896&^9igcyy7wQ=8Yk;$|GbL>Tl*;#@l~ zmBRI_m^1}4OctY!A&C>DWicGINRuIP6k{oc$cET5_nmGvS9s?6IWAvWVSLo%mTM-6 z!y!kXU*^)%5QD+B3_4Q?6qC`lPc5lO1CEDOiB^Y_zo==KN1NrLZtBuT=0BgE(& zbgQdbM_x64ux$&+c34?mXVU3T(iG4P`}x?SKNvDTQAbHbeYDEzN`ye5HCR$IObmasP@!(wj1|{Ov<97HMkxd) z)7UEF|6}jJqbs&jW$S69w;@;UqLyz-j!{V{hJ0%O3EY|9$VG4|-{ z(fgcJeb#T!U-|}BOasd9uT5xExqC>5!( zNA-dFf04q_S{f@S4MHH4#`SE{G4ajw6^`#=Nt-M$@B<6K;-HhlOf_{$q0A^#qoD+4 zOG-4Z1E~eWYcRb8um67d(phIf`lmgSU%!m({|1xal1L5)U@ zqA2P0M`T&XYwoy(TD8jM%a;i}kDZ+^`w#4A@BRavKKn_wwmYO*POm>;bF0k*uX`;E z3yTc;eO$*vS`KJUtI=R%tBqDUXeh_+dnGV+(x^Wo9u65aDjYg6!}Zl|vaCQ90!oW? zyb^I-NQ#nD7gz#@!#+C8N%N9^RN#yQNS9iS+9O=wW@55RGK|Pdp>{A2x*z=?@KSgLhaI3mUb;5q+n%rou{689?$n#TiZaRSzF(v)ogHR|8BB0BTZv0 z%c96)v?>u&Fp45JHnxbO5mIV=*CNd_a;3DWj5H zfor>@sZpubl;owtDnU+}F8LD1Xh{<{&;y5Q$*cf2x z?;9jKN^8*_j0gjVienK)8A+ZIM=72y2wayq%Fs%iMWSV6IW~pTNNsjKscVCFkO;K4 zkZ7<^*RVaX870*W#N;iH5^DH}(Y zSXPOWW~iu3U^gt3kfoXe*flNbbguE!Kl=+j{DwPt!|QKjs##&X6Lad^8lU;-FA>Kj zSt-#P_AJfd*pj9B3U?iwC3Gdj;gI1droFYp#Zks^l(W3n4qBy4-Iy9>`zGG%A zTC1^5xk4#Ll%zPN!nP!;EYTX$tQbR9xuG7W6*#uhiY|%*DZueuW~Zmg^PJ_CO>E2N zwp$M|J2T17PM7WW4z_L6Xw<0J>v&$k>e@ORo15Hr>oLCjJHH)Ol&r3-(Cv?q(n4v4 zHSPejte_IO)T$M%dWbCrolcjsX=YNcV-qI{j%y>d(K%XP-C=cQi(`itSe$Q=WGORE zfwUHgR6(yFVQZVR5M+hILD5Mvl5$Mn5n6);DTL#@q=nK_3L%g{X^oY*Zv;>uNQ4k# zX>khM5?o%{rYIGjZDTtHSqfX-oJ91<(hRLidfg#HVI21y7b$ZrD~Iv&RjC3^i6bFT zL1j?eXHwWt{xdkgZGQNB->UiH@AdfCzpVMK$6w;)OCd*Z+QFE2r1FaAln|(`YOd~tCX1gsVQEG-#mL+-O2Ry z1l#Qn{a)Yj@bjGZPM3|Xh}mhM*|`M{96HDYuX`=+tsU0a*Kr*O&vnSN6x%VQ5HNOE zqfx}Z-Sf;$*Xj5BtgfwtHe9n(8F^pJk_gu&D{`h<0ikQLwbkRv7dO}$mF!-er&g=7 zer>~)yRJss4!Q&{usA$l=j!G*0J&76EOSL^3(pp!Un*=YeAgzAOZAn?W%d}oG|*a$ zEX_$vMQ>;@(SuRMC`$39;M!(PwALpt6?tCLNmG<7@yAMCC`q1+G0Ra<#3fppPm^sc zT1`0l0{rqngI{~`hHyEeqOopG_-o%$;ijbtKJaLVCmwr+kA3nLS}liNOG`{lwHWNQ z$+Daz&8SoYvb;nCqfyLGXF!_eWI2Ss!M{vRGzdbE!7!#jh;bdTghQTZ2qd^X+R z2+Otz0uSGHx&OYS+f;6urLD+yi9|3v5z=UeEO+`kbUZOR)e!CWkbXbbj%(3RGcigt#<$Pt57t0KfXi1l zse~T8=IU&BWBQ}i)HfQU0)FDVj`2@EcA2Ly4A`@463}F6fv_dEt5I50W*VV1g|^Vz z;DII@Fx2p4zX%rxP`98OKv=JH>T1CI{$a%GHOY3A6BRDIcGeMBq0LwP~ zD9g4G+eZG^cOCk@A(n(z&A-uRH|jNz7I9LN7vTFIN^2^iPw7c|gOM?|^IbD2OftIJ zSE3AaJkK$_{-T6@teRTnco@k#&vVTC3!|zU_%6%KYpkqoU|W*8*%@k8!yh|(_yDiF z|32=x=YDGS7Aw~;bK&#=-}SI)+V=iAB(rOBP-$n|bTBE|fw@rd5xa~-jM_=gjrI)tZ>-*?DM-?R% z-=i#Z;z14|sx?p7LPu<@Wje`}2t7+%uOd?+`hzzRz{nEP@QbpDh3OiW059~|HPzsi z%Nt}uFgNK^^#mx5)<)=3YE9Zp(KSs_waAo4l>&jJ)B@j?bh{-lcHrs|4lj7ze>6Y= znU-8$)javK%_z(9ZR06X82=G=&9^wX*W=QaRbzc5Ed=HhH$B}V@B{K9CyEmCLUH)O zKC0CUYa82)q7>UUu+KCzl$hZtVzTKW5cGz9Y}=v|22?5)RH=*rsMbcR zr0^X{96>)Gp;uzsJ)1$2Q};{W@%l;TW<5?{Dp+0b1DfeZNR$<94HE!e6s1Vx4A+%d zmeg5ZQVU%i%OX={|0^YcUk#vMsdb*AXao)dK~X3|-=PVmuERB zf#q1pz(!G!=87`cNMWF?MOxz60^f6(oAo$$K;gRssWSfd4uqKu-1 zVU%H60dM)1dl^R0a^mDwYIPgmu|NoBrH$>lG#U+Nr)QX&n!vFwE?m0GR=Z2B5}3cE zFx`{Zilj)%GC{TC<9R8Ak&)#MgAmJ-)an&R!x$+sOwF>yk^;-NjK{lW_*iLTrpQ7X zgrgdNpaf7Vh17~P%NPuXAYgTMo$0A5@;u|})oW~SwdwT-H#|0MOHvf3#Re#IGw;c> zoH*6AS}y(m0NaAiosM~*kPM>)OTdA!%hPK)kp#~*QZ0psAhhZBHP_dBZ0)4<1RTY$jTD%Ccm8{4;W0;^eP#xx|gy}1^KmbQ43!>PfHx8(|nU-*D$>l4X3J|s<$X_Kb zz-41+^ZNs+3#_`Xbx0K%+ntD_RAamD3Q1OyW(nJ!KB*2Vu}I6oT9WqdU%dP>C)J97A95NmSAxrWaalvcl7nm4RPYEtFt&a zTpw+I*7#iggL5FpSJLG;E8no)O^EZH#i@RoSz7>WXWVG zsCzE%lsoR#6-SmNo*1#yGc;dcSOmU@76l?x*tT)m(@65%2n|F@F@6vdX&H`LUT7{{ zUS($x@y0{@(AuQK>B%`h^Z8Q@ql|NB&hf&DGxP>SYE{?VmC|^v#%WHT73LQ$KuF0b z$yjS^9IGHravaMt+RW1Y8uC0hpe|(s#j@p%n?#RiL6)>Z7~ zK}*l#z@ozsv?kC+h7=`%@8DYCN|VNhQA(CuEGGmW3(f|D`eDU#*9e^^t9nq9nb+ z`Ld+MvIL%E(`?iY`=czW*Q!WqvAnX06gEkk!C*+OQb9O2MNuN9BuP>;n6xa?G{y5g z3fGv_*tSKU=M07s2#L~)(Ws9`V_C0y)rMG($GJ=E*aE86fGo>N(}XMA<_X#kG+RDN zYE!KR6w1bO1p$&IE6G)fU-PJi9;^KhXD6LR#FhOY)wFEVdKD%C?}>apu}<4B)u*zDTXqF$@f8w{xgKGTyENZX>*H6|uO;4>T!5oq$FAPjt5M>AEo zsn;u1<+$g+X6>f4I2ivx3wQ7(hM!;MfZkv?w(uuSD$oIXKOIOx-`nhu) z+8a<73Lyn~R&aes8^UJ++uJ$)EXQ*Nst|Odj5HDShb6)W+Y?Mr6l8?~3*Nd^!FN3_ zbQ~aNX11l3&cw6NUC>cnh%oT*Tw$_=37-FI0G$W6Y{#B4<#hS|~PuUr4aN_J5y+W~M!MolA zZWE3#OMdZ#HeWt%bNhaeA}{CUK-rZt7)** zii%$)4KkzFHN*AICEMd`@ zs?zVr^g1yzXi`cGWhdlCK{P1n?sRz7;o_D96AXqK?eu~hE!b#FEGP4kX+tSIJ(b7E#V?I?|jSKc;L0S)9;U{I1b8^ z1g_xCuM64f_gLT9pk8&@vpB^IFQ4HzfBOr3=6sho{S54xfJ`e^Bf)L^YBZ`Y(sGf~ z1EljszHdd0n=O=u**b4rzxCJNtknVx8| zwy{l#G6O&%D9e;GC~q_*jO#wvG1zAfSVG)T=5}p^KT=vDBt)YTW|k=U<^S|i-u1To zk=Uq`F)FiQ(2qEMrJz-H`E&2NmjinbG8kqQLQ)N?IDUmfX|i0?=?rmQmn+Mw{L%+M z1?0@N0o7kvQQCnm0TE{fNphoRKKVz>Z|AvFm-*uJYjit3JdrR+O7`y!n3)UwR2;(H#JZIc%nWvRv}M2RRfilW4Ee0<-h+aE9-M%ZH=MDzD4ZVV@1 z-NI)%RNas`NeO+Aa@_Xkd5$i!8-8I?9HTW1heJlgA;0>8-$v+?TGb`Xa>KgF3vN5M zpL=gV!tSL-uCDITk7BSq7G@jNsuj*%TPKSPT*pS01#f!jZkm%#{>3l;CRbN?cpTgqW>L$@BA@nRZ+ijjZd6wx`gZuA1#O^7<^Dp;^ zl8|gYVw4Iz-{ru81KfV+-IQ6x_Vx~82uJo#bKzQ-iz_208xFo}(-I2Lu~6vo;xnga zSIn#?i7Cngt1um%;~1eQfgsIFc6t%reoU#&u&`OR$Y&~CT}{Z8rdA0km1!ojJRjFM zrbE&K@?7Hwrs&O*5!JAQmNs#m)2JEts_hsAQJ$Lu+KdBCrzOXB2U(V(wIW8}=)yoF zq_K_~4hG|S6?FRpgjRIBLwrx-yCzL$X^s?{yY4v5+V+rVPCQ1YEvBdEXm_?rGI;%K z@1oZmaq-GBT3WUmZU3~HrpWwvxfdb_2g-Mncro1o1xg)5s|?3!q@fAaj4>;4N>sg9BGL`Q1le;o3$HL6((;Lu6s2Kf zqs@-pqf}!g;rJFwl7u`jAOdcp4#VM)>uYPY`vZD| zA=~W^ok7e^hxYM3?|Kunvr|xd?4F-u-@G77MrdKP)gJQvi|1+7J=#Nf%4yzd~s3+--6Z&*;Of;^AOih?A~NRpH|Nl4PnEIVE224-1} z|5>kv?Am!6Nn479n@jy%}VZ57l42mPv;LFf}j38hllmi-lhoEr#6Sz?djqb3cTfVVBlXw)}2X034@hrEQP z#X0saEzoM#Y1AuZsTp9VNlu(*j7Ax@9iVLw->a~H_X79da~HGI(_FlGois0SDgjxZ zbM5LHqc|hZ62A1*i%iz5c#b7pM{@YkJpEpz<54WYKwHwhL`cyCKJzEliGFma(TgpI~imgW+(%#k0q0*6VC;M=Y;& zc<#gnv@STdf0A1cwamoTk{sAQ#lmcZYUr_RagHO0_Oo~Q67#du%uY`-HPIsU13cHp z_gtEFpGsgG)POR#!d73?8)kU6q8@0bCqf$aVC;rtbe)wh%}pXCMFG7*PN6KMw8-*| zUayBB0dk5W$Mqa+$0klvoN*d83s@uZi!gg7RT@BBnq>@zLq>5%b7IEq-_YcFifw`C zSsZ`)98W!ShN8&vd~qXvQ!2EwaJ&lpckQASFgZ2B-FMz()IA)V_F%x)PLJ#B>nNdc zTt_!5A!Q0zme=&2xfug}iAFs0;$?<$sgaVvx3LWp^CjR*f6@T@Y+97(;!F_+E@@uS z-ig?=Yl24I=gR6fLJDllqBHE{xgK}ka)kTtzKI9!xt*i?_Oiay<|DuR82{mepXJ!$ zS>`7rj-R|pvpL00hxXB|RXK9>W`5wueui6)9bo_d-8}mEIi7g@QPL!)-|Mlody)06 z4reZ`vavPf+VU37ddO>zEpXGmN!s0rwXHs7kpjjxvOgRWC-IncnBEY>6v7Brs8#TrRJs#A!k$fLfq&Y)F#K?4ZWdD$)|>B_%@G#3>A-9LI6Z+9HYwJb@I3 z6Bqivxl!T-Aq1{xEERZ_aw7zBni0pz4M>P31$jEcbwP>|qfrkDynK2E+p&1ZTVIRo zx#YPvHsQXDkWg>bsnx3NTAXKoev(go;W^&_p-)rsUGBQ`7T)mMJ2|+t!0hA%d-g7p zm!-(F3@@soLdDDo@L=kNl_?`<}+W30s7;F#?J#!_4@JQnTZhF zv02&dvOt5O%vsy+a$xr?qR`YTAvYh{!&IxurR6mqdF~_^FI{J4bDOR9h{H=w_DsdZ zamw`c1h?F@53sm&b&DVS-tWeC74>?JM%88nbh-8(5F%wD22SSKk~@NdoW8#q_k05;kgDFR+JXI<^ry+ z_c?#H&yl@Ne7mH`Oi&hMW~fpiv{`BRo=(#&sP$-^_)|vcR$Iak?^QaYd1!${|_0P3R~p6^}x}(tMqT`APbNA!T0T*civ` zBs0DoqnLA7uJhc>=h?foz(k|Y?>_b%msU2p_0T?!99*JVtMbrYx3Ssov9;c&UzElu zI9FnId4+>}rl^O8fx9!z5E3$jgT4%W`oGn!=h49+`rgS##jAJ@72l%Yk7(7*6tzD{ za2=c5ZaGZgdi?Tlev-4-mW?R3QrND`o|ziA?lZNB6V#ZRY?7Cn&CPA@z2^?@d+1Fd zEe;;L2bKRk052VXnOenTW2;RNgxq(}Yk2aR7f5r3EqxlzNtPB`EML3A+aA81+qXvi zyI*}2WI-hm=rF(<%kNeyp|Q-~DaNY|o8c%XuG)-7LuwV5D9Ld>o4Kh98=F05205YU zpj3|QNW#D)&x`TQ$Qsuv0#zz{{fNniN2$z0*0L-k{w+1RC~+MJfsO6kM*Jbm$?}4t zH2XkHT4<%mO+Y~!k0|m8RTz5(ty1#D=o{X1#}SI6;IXGpQm;s)6bNm0b)`~lY;;MY zjDDJN{M1Dzn=NY93NM{L&&y{oQ40g=)sXp_7VmlcxA3O>U&C*H@{6o)?J!CbjS^yM zW=dO*G*>8SDYVFPh1Q$|PW`tA(3gfuu^dI&f!1V5J*gs4OitE`m8Rbrva~o&x7X+2 zeBo*O!x8f{O?=lOaI1K}!?eGR6fyPY6hW;;;GLt_8*%9HLB8X=zYjlbqT&IKx!t_| zJHDNdeC`AnE?wu?kprYj&bjlan3|m8;Ql4fU%1L0x8BUso&!{Tjb*Jdic%iB{}3CS zZ9ed^S5Re)ZM&dBN(7;z~>SjzgNK3V8gFa&P{x1<*y{)G$pCG>QOQLbq@5KTER>&WsYms?X~B7T4A{ znW)$Bq=(QYn>#zq)(hOK#r)hn6SGTH>*f=&v}Z4GddGLNu;&;_91{(Dbaytm>&|1m z{afzlcfR->2lifI&(adT!H{O7$&bGGy(lf2Y*Z0Kaq0XCD)lB=5%JPXXSr#Ai#OcW z;@MX^v^zZpqY;7c1DbBHq}LyqcDX1~T9f9QvY%qvM(I5(GDPa&c^+$916Ht&mbF zZ8kRh^d$r%pL(rIr5Z9hHNni3nM9wwc%4!FB_4XsO=7ZHV`nhp*wQ4!B+*xvI~tHd zDA7WplwJkC@YVXpf1=Zi-&(JDP7t_k?F`sz57@JNnrby*&>bOU&Ys04s<7#FJFIT? z2t1gpd5q$aQIat=Gl%Wgv0a;Ecic<0Hc6VKq-mdiXPb)`&Qs<&4?TD%k3IezPd)dt zfvRQ&XD(df&;Gf+-1qQXSh?^D&p-7P{XxM-JA*PM8f-EgbXc6NlIAJru0;%@5-BrA zqu8Vh%Q72r+oIG4=8))uB#j6?iR(xsBa`RULYuYi5zU%Mz3LJr5k+A-HUQ7J@LYv$ z3s$!?hLIWvYDDDNc#goaHMRwnici@eAt-SiiBh?-UMfo*+hK8jn%Su)*Ecd=ceKlE zc5U;~<37V-fs#!eUr}O{pG(lhqnp2wcyi)9bM{7^=7^M6=@X@au0Cr_QX3mrt(>gdon0m6X={8Q{!UYr6aK zyeO~rqXa+n@I05iDCu@aOw|J%X|XU<<@SS<2x+m^k7+hT_AdquMkPCg9N({_lwi=` z=E%`wgkhE8XvC=BV|#stjkR?K-5w`jIYqM)@W6dHv$hd&^6Ui;?%&IBnDVAKeHVZ0 z$NxOXUwn~E7teF$>MG0YeXgzzNy~ulFz3RRH74pl`{sR9#HKmjUZ3G8BF}T9iYwZ%>mCR07 zXf-Pgql}$i$|#1sv?vP0?YA8h#0cYz=(rYP5a2ov%j+rk-dysR-kI~CAI*8@yrNV# zS{sL;vNS2o%w&`SyGd!FDP#Va(u$>B)1-OM@@gB2O{G#HsJYBd1>CxS5ntL|UEf9u zn5xwbZlEkh6ldbl{y7}i;o{Xb4N&(TBeEYN zR#w~Gba)r*n-R06L)Fc(rA;AxcF%e&whERv6+69z=U!gs`?bf)dYczsI?e9=$M8lm zTkD%7Nyh5RHIg)Er!!<@v&+OpgW2hTtJl_OH5$y%O|aeG=0E((Z}P|^kFl_@K$d1? zMPc|>nM0<0hEb2A=o0u2Qw@vN?eYeqFiCQ=Ew^pL(8YHxCh9(UmeL*MI97>W^++;_ zu#EPgElY+m_>M-Z(sXu|lzC2WJ9Gyno}-y)R+*iwvE45j40DE2OmCDiN(8mg!*zl& zd{vpPlqxP=9}raQ{OG$}o<5%NiKpQ9J3?xWDK<7XP^F;}S<Fl5pkANYC&3PLdRvIQN^)r5&AZ_-L#L# zo;aCLd9#+`hfG|R}cf?kQ4@YKut*r9p+&=oIVSkZuJ)jhI86Ga8Ue+HJn)&P0| zxH^o}Bb~vBS|y+}N_pk{3J*Vc1d#+>yf!4T9S+UgSdPbm{rlLpYo6xB1mE+6Kf*At z@}J)K^IW*RMsM&0m#=NHv^Y<-Vi0!g8(Wm6CL6O7!oa81sIk-OarN2?uIms40TUBV zdc6Trlo+~>(Dl+)W0=%F?t~6zyqKlF!E=kIg-7__Q{OwD8@R2K=T}^?K{ktc5 z=-wmZxtGrCB*{e>7-sBNH$kJ%kJA@lYXGePpH0)^$l6wqg_#Lzp+}TtT)MQ%zB!34 zEoLib&QWzM1VMo1xXdl=!4V}7Km10fCMR+J+5gESk3Y}nANewsFl2sqmTDy+2m_)h zA;~hbG$$`heBabuqsSPkIEATWYGb!PtqUBhAapI#C`aSa8)>2>C-iOOj+~Xajzv)l zk~A{_v^>YMYq++J5T=H39f4y@ilHJ-OS&V?oj1XI9xC{UzhQB7Nr8G5jR*@9iW4VC zOiefplbm5(pp=9Lqu43Sry$LQ-UcSivw)5lDXZ zXJ60MqR>C}A5V!_X*Rdrx{v+4X7vX?`lJAuZib9fW4tmpRC(p=5kUD^()HWotULrc z^HVkYqlDFsJ~u5*5O@~DI7643y?b_1uh$I4M;mO;@}-mP+q1wgzW-x<_}71l4}bW> zT)cFhtIKP~8B`l$p6~mFVL+N0LZ0V3q**~6Cr0H%YdqItvK10V100#-x;9~@!PSjT z217-?Vo|GB@cj@`6WCHRj0`>}FH6wS-Wf3(8VTGoFIaC&sv#7GB+V5!?UvAloAwII z5{gm~c%ZSUg(VN%B6#wIqFymx5~Z>z6yrAE1d9-6kLGx0k*bxz_AE*TNm|m4N9^8J zFgIDSwq^ipSJz?ws35FVSxpnRw>FranWa&$(d&+kgP3K$Z|HjzWr>t#I&SPpOJtFd zmmn+{CI-m%qu+fqw>>b+d*AzEQI3tsUU$a<-u}8<^=BVH!Dgqgr4#}wi~}P)54@j0 z;j#Y&?>PbdKV_-^_4?L`rFoYiwCMJ7iacey>2rM(wtE?V5E?v?((JU`L~)Me)zR8! zYkh@%dzSd2@Bcm?dE`+(^ZCcMq&@4_{{Ok{FC}3d%cFmOZhl<&zrZa*&4rsP^jCz>oN-XV?X1}MRaf275 z42wenXrl&?u*eIukAsp=erA*V>H!lKxVR0cF2m~{WUM9NBBc=?^?aWwfl5W)fS0D3 zfxxQq7QY;?LKFgNF~(#SSiqlsc!p2^r|0>|vt0l&H{Iax{P>^Y8YWu- zahx$Q5_Z4#2bmXNYXH6aP2bs%vbQX6_Ly!~2|XVvODciQZl}ddr#HB|vcus6`zQ+| zQqWu9V0L~F+nej0IQ}eK>sy>Wd6w=lV*j2+Hme=Fy#a$^L=+os9NV_=q(imhqLf3o zuZWVIoz8%ny&=7R%uX*QFD;_wh~Y5ju48lj$Pc}T{reAc{@ewkC?fEEPP}r4$yN); zw$NpPL{Y5G$mvY_AFp;iePCW3QUJ4T9cN3%ykqXM%{*y=ho8=9R_$vf`1Ilm&P zg_5Nik3kl)GZ;HCI}UmrJZY99g^d^o5<*gx1-4~wwfUytgP-j(Woark4~W@lLpjWh zI)`l=Zw=d`S`EfQ2aaWv<~jAy$FavQhDsp{L*FTK^Y6J53`Ygu`-WYN;)1{ZYZu21 zHuQVn^9H%T)8ilfyHB7LVrHU3C3LvHKAc65Q4Pp?p~*SPhj1N5Vm*_m0kw$~XAu5jYSNj5gSbbAA{H&P1Mweehs z%yEp>qY&f5SKZLQnW#IYnPh!CBFPFf?#~s1lKHtw-uC93x#gB)1b)D$KJ^)P?_Olr z;sRH$U1xQ5V+?u=sMo3pQI4f?Gb+C1z}`7#CtPexG8m26Y{z`*xXbJANy&0enkfR$ z!gnO0S8)3wi$p`CVz}vpVN8-5F>tgtY}@g8&9)_43$jd&%W8@12s6U`cq%MF;U5Kygp_`aFT6-v|T$Fv7ILQ52qVV1Ff z*5SS1eUM9QDV<&hApT$Pe4U0g#vro_TbgL-JQ(~BsIG6#MM*!b@eK#WJ) zzgohdzr5b&)+4i2t3IPq%5XSjx;06Ay2}dcDbT7_qa{ zr`zwtHIoJg{XUZub)qPy)9o=m(PA)w!Ei{LMr^B+EUD0}Okf2rd2VJkm(~~cjMSLQVQygI@?>@%uY|>`?et@dJ^9;q7Fg|@}ximd-pF=-;U`F zW7fJcuC4hi?>G0(hS3mL{aTU*;4*fYh|70Hn9H|?w9JFwAJG-_3bqlm5TE=iIS zh9MG7uh(M`#Yn^rin3u?q(qUS1UrslHh7-P?92pLm$zv566zI)tDBk^FTuGL*yzGS zOVS+`+qqjv~Vm%b8Z$fhIm+(O_D~;%*@cN1xB&e6*#twV>|Tw5tY#BMF(C$YrDWvfUXniXw(-Nt)|xz(4;= z(S-kA0dyMp3&20j3U#mJ*!Z4Mm$cxN~TWUx%jZy-o1aTa*xw(NtG8jZO@*HW|Z0~eQ zvy4g@Fg4X8Uo+e9C{92awrW`xG-@G}lMQU!rqj>R3TlC1XP|ieBwXEuEQht7lC>Sp zU;Ym8U5hV0s{qOBcFA^sL|$-1{lj&P%uAY@PSPF&$BMEZrUZP)sRm;`jXu24ym{{y?)FG(7 zDroQQ7*3b8kkX_X+cMqLRyQN`B^4hw`|#*VX!l_69PFD_+_oRyc&FghO32z)pJ8HH zq4QI9+P#!CGlnLv>l!!bvc!@uMUmtCE?FjslY%76jSGS0;5dm;6fCcHY1UkJ?_OkX zX2#5nvJ}q`nUI2E^a}m{2up}@U8P8~9M=+KNj^)}Q9`g@Pam(Q>0XO3T?5xVSOXo~f2!g_m%*6KsR`}iu4{Z)S5wRR`|`VwFF2@;8>E`sTvEj6F^B{M-*vF zG)fRs5PA|@z}i-dE;N%(u&tc3%*YD^@QNdo`n1+mD*<5`&>svD5(7z<0;LTfJ_tO* zz{i%bvE65NYeXfGc#ftPXpUciID?z_YW~^(ra5>BC~Quxn1Xq#;o`Z1&M+CH4lP{Q z1)+(e*l_bH5yBMPj%`s`1$kDoxwXT@L>)`mXjRhg4G>m?WAzr&vTu5TiK07;`R9wyC{zFVo&CwqwxM7d; zSJt_{-Y4)RGu0}EFwT7aL5#Ev4LKeo|LV0GmC$D}91w;+Qd&kT&$93w2U|+26_>#% zW0VT&vOpIwQH3v`Hihy7H^aVp%ZyXKkcJo{QPe`vTH^U0jPjazGD-OA~v?SIdt#sXXbFCOLSZ4WY4uc}hRx4&&te)tog;S-;G zjIEuBO5o9$s1V1-0#jHLr3HgQpWREl*t4{VZCk7?8>Nq0H6V#2JjcPZBths=l#+#d z&QE_|oyA+<2x*^Zo;^)_RIu6}@R~guYnKnTJE6VFuYchj{Sh?khS=yil3}D6MHz9L zn#O>y(Bx8TOqUj8B7y;(WhHEPMr^bP`tW{NJb3r*+<(_?WYWX+D+np+^?TfT*In#C zu*B2Pyu$Rf4a!WPkz;rV7R^xd;QlFo?TeTB_?NGb-6S2}^{sbv>yafs_JtQYb$Jyj zq@Jn;A}t6r2>$Q+}Ct_!(%kh^_l4S~sfq-gV zpo*NpH*zPoEpaV_sw+#4#75|xcDoI##Bx2Nq)s*}^r72!>r55$OHV$-qtBgUn8GkI zKJlKT5!#FaqESqtG_SkwZgwrqp|zl1tB|A#n;V<>zGsX@JflQrTM|o_IKIcs+$8tj zb{p-j4Q8hIv+KY*zxLZF`QVrP9NRO(_O9^p$8)}PQcw*AuB9pRl6E(u-ODKQoG@^i znQEH7UXtLrE^16~R?5tX@oBZ|6BBdLrXGEVv0N^ z8Vzw>n>RiD0581w3ehORwFO!UDm5S55oB3Oqw4bUXEylYIJ6(bwHl`&7F#c$+09{XviRjvm(p za(u&B*b?G2Giu{Xlc^kAN?g|_%M!M>2NZckJ%HUu?$hCUv>I$|ZZjARsRki!OHvf& zcvq!x$eG!FkTrXrfAnvk;Lw3deedV&Rn52m)R0^E$H>-hCMQco-o~*s z8{Lw@ND;b{O0$9Qd*nq<94Um5*ke|MW!Xr9u?Q~|aaz!D0;VQv;FaGDxv$EDl2-q{<(4Hg3+Gb2qsB6IA8RNaa<|Du62(;~f7>0klDDv$h zPl=NVDHT#E$|6HjVoPuxfh~<(eOcxRU7}QmQaMWH#Bq!bG$(iIT5VD^8@~Dr|L4EZ zfA?Q~KwP-8E~c6xOS`A&$0=!U<}6v3U|HNyZC#w7rPuA^`7YI3)hJ}Bk|fKSm}pW7 z0}!B8IacP7#DkpM_D#{4tn%DTYvSC6A#3X+*4B5Z1`aEukSkG(h1pr&{Q7Uf^&GBk z<_sdmL|x;$f}+R`C%-6}nQn3Ok-gMw0Yz?Ds9KjOU5;(P4Z2Z{=U)>wDUR%!#s*HF zxq|K5H{5>8QcwxPD97~X=F{l*ZgKh-fs3r!(0gpWWjCE~&T?V$$NB7JkP~yt+mOz#q zT%6PkvvtFsEfORJzUOlJ@>QcKkHB?3EXOfW+&rg09GQ-3EOg+xhNd$!v%o7Cx_t6; zCs|qT6I4R-5|RYgw};e1xc%@Fx8HOWAp_2>fKq}33l>%1LaCB8%cxZXcFj+5)8W1B zTA0Ff99+i;_gj|4a|{+pTF|V!RDFZ%+3xmO?+oxmPt4Cwg5#1V5khEu-(|bKa{~iy zL>dfSL|CS-(poXq40+dE?&iSWS$_Q^kMh`aX8}~J?x{G>S=|~CC)pV(<^L82?)z;^ ze!b%7YfE89!PKZqoL@mGJZHpKbsTl&=HRpCp;t^22x=UXz0QoXu`XjMxb2kO-T zAx-t_dmci7=ex98Eqp&D%d)Xhff^6_Ov;Pn0*Weo7nV>$>MQHpqSFN#{duKUAn@#b%Nt$6UhyY$b$?>~#v%d2`<%cfDaL|#g4S>St;yhs@gO=^si zjGcD(My(~JK_qGkB^uj-I8BM8n8{|q;-rl|aG^}Kq!dDHY{$m;Y@U7LBx#y39828^ zZHS1TYgGDO+ooO*DTK#weBxQ%9mLvo96j9(gr+3Rm94a11Ah9pBTK(~$I(Tx+39lZ z;HWGGU}|&Rzv^RBj;J(>4C{=vyxh@B1629}^Nm@0@6Q{d8cj^KsE?ni_n-6f$ZTqopi{r1H=j!UFaBac# zWNzvx?NKUCvo(Pff;3AA!;saLHH4H*PPE2DIzf`=NGb6=PXN4p>Ks4%o!`n-vx(NF zyzS@#-Tl>1N`TLO=;LJfyq5N8$k~gVOtmaSP)svyOJEfWAqtwIMHzZ@`zfl_l&VBH zX7=N{4r!L-S>RX(Mytz|o%R4lpyy{NMJ;ed7=$3frr^=Xo@RZ0g%@79geYTzz#%US zENK+%Ezd%u*y)e-)wQk$^kgH@MNx=$C$*EjxC;880>7X%jG{!WY;=qDt)5D0Vc;%MOi>JPKc$DXYUK*`Q`n~ITu3(gw;~kU4vL&^8 zgL-3v_VyMV8`qhcnk3J2ws&@n+fHuM%W#+h=wEvOM?}Sz(z2y~?$jxfB#9W?3F!}f z;!Cvrx){WImZ7`q#yb1I%ud`)vRiB`G>E9RUOm5<7v;+@oWA}qZ$CPJ z?@Y5s6eT#GMcDA!+8!{9G7j#ZVWJgs>FPG!QO@U%cUj+CW2qT%V7g?jSFpX)<#!)_ zkt6%&x#On2EX>SP=pAf3XOtFHt0BJcF+00}<+@zEdX49veF3cno@WyV{@8;{;kh1Z zmSbCx6otOHvMieQKwP}E!TNGn+qM+5(=8_J712#oibCtqwRNd&x!i9uT}kns6e)~D zwb2pMR4hrUHLk7AjUfbs_`cDe2FJh$&R&`}`-rPfqyHJTF>C@qe<`zHNawJTy;!Lr1ZoUT?C!Juo`f!hq7064WZb3D{B*Rvcm5!rt7;mJ6kR zemr6B{ZaGZujj-49}z&Wo-=u&{^G^u&Oga>?kQ)rpQIU!Gflqz&3ADB9S7Ok9&vTG zOK)WIbE|4IU9oxReRIsUJRUnepgqiyV+O%f$FERxr#QMhM8|?!7|@!W;mVb3blN+( zzQ@MK1_;4$7~y*kagtHwDZl;cXX*5YwCa+$blGf=#tw{zgqWozTX9z)1cO0@B?UrA zp;duGi*%GBoq#YD^tvN_&p}%@+dGCNSoa;_NDIpj2aS7!A$( zWt3@Rs(5~YK#}Jedlsko{1*+HGV~49S4!iRYFiT5v5}UUNO$|mGlf$B82I?tJ3zjX z0tn#CWvPDp>e|kmudMI<*nvGWvw!`)Z)Ml~1Ydgk6d(Bb^YjJ-90A{P?-bvD?>uX5 zQ$T*`$u*ul(*i8AU1k_w7eoHvK_Nl4i78bv8D( zh+_j{LjcZjfjYRwA1=Mn@Vqc}zAf-)PizPU|S3TCEiOf&*^dPCm#sdEgh zDZc$LD3T=M_FE6|?(cdBzwv?JU}bHSsxK*&!m^DZgjNQ8SL(8xm+EhPWxeyw96$h% zD6M;FR<5t?{FN_0dFtk^?vQ7WUt%H@e9wck+`Vstsiw#EjS;`~)GC)YQ$}gYZAE?&IM`bL|Tl?~z~qtrQp zYqNj%6cxW93`U$^D~U6Uz_Tf(#19;%J;}^e6W7*MLyypRsRS-rni7USo@G%9J&ra) zYzrg~S+>b?w8NgoJ-p-LdwB7c^St=VMLL}xp)1%mS7VTr)GHog=rY+1nVrtLaAkwd z?g%ImDB^g;y?5Qj6OS+P%7sm;0l1DN@NA?d(Pe?wiqN+=r1+y1G``sa2;e2)M4IJ~ z{mvsNeg?#A=4M(4|3BZpo44Fn_=jC(PDa(wz zj?FRIs$Jl3Fj|fCyq1xz^BM_9N!~N^Rf4Y0hZN6ha@vZz2PvW;wp|F zIfCRcg-VR3vo<VNZn8oxFt}CQw$NxQe#yeZ9QBGI ziWRw`~Th-bfW zdYNA%j|Sx2E9JTc%r*&D&&XWbOgWZ^U+7o@|iC^%X{AT5bt>S zKD;NN<>{9$QYy{C#jBg| zda^UT3#=cQo$w#H?eGGB`EC1|U37Tzv1xwp*H7}1<6Gn!kdmo}O~oE^=E{I}XJGuj zEX4!&9^lY{J?z=Lmv4LfyKuuAAAJ9>a^}=Ys}Zqjz$e!QaZ=J96|8lJ zTtBnM)=tE?K6Dogvl9peNoG)lhxSe}IZ@^6@)oO`J+xA6bw+^ZaJ|p@v#&5Qy_+l^ z;5sc9=NjzZHO_UDQ>y*9`+u+g}sZj>{^_o$aAhEa!%Y;IcjCd0x;N z7Ho7ODVX%4k&orzX+?1Cei znq4GG5>7tzBy;Mr4~<9g-~ei&?a&s6C}8PA`*z|7i>QkH7qmgS_shNo-3J*ot5J)l+o60q_3Nh_F&)H0V>7aNxk6u?=~FYg?2;6Z$S$ zmi_xYFIFte`Xd5DP~5gl6Ux38ahd1+>0FBeWfhikb6l{(eX!=@akF3YIsOL5M1obllCk4Q68lPlv` z^i3H+zi&*c^&d>uCxIW)Fn{^V^?&|vt}egn{##npo?Qw*kVt8qZn}dWx9nQrjyvw4 z)RLv$dzqY`XHRzs%BYd$S<2e_21Qwtq=qu21>{*tmgT&1`Vt%M0b!*@%g>o~MpTCu zmxcwm?4M!(T!Y{I^hqvmjJWH_Ebo5(eqK7i%|zWJ3>-9qmF*#yHilFJmqOWedR<;V ze}xBbS>$y`>%8Z|T?F0{e)&^p_@z&t<$Bw6WtI@jApgnr>!YWie(}tIdiQ&O?8xe+ zm$6+JVU5MGkQ8}Fy;i}J;)`ikKI!{^Jf_EzU@3`bMc?EBBm`xl{vcxV)reYi1V35v z`3Ig={9xb*OiZ*0gDO#!;o1eK&s^rY7f-Xkwu$F^Oinb}+3DbVrXDbgs(m)Mwy}gH zD~$@+p1B6LZLz*RL`awW?_6SOzRs0vS6Er;(0^r!V`;Yg370p`Lzt);Y|&P4$n}jK z96w;Y6LE24M3M?TE9I{JRsQT74zp*j#%m7FQVo4xxE%AT7q0WA=T~Tt3VmqL>{C+6 zFKxBEA52PK%%X^M$DjN)hVTBr>eIUzv{&$}O(Q;Dm_g#yM2&8L@TqI7TV2onlYzoQ zPf+)YZ~hzT4{7yTm~?S%K_#%R`8K;RuMD~Oj%hsC=fbshHa5D9qL_`XggDn6IWTSH z^EP+b+}NVf!kDdTMKsbJI517U65zTvn_D|Z#7YX3mdrFfR#w(Yl7!{;g5}kii5e_U z27LCp6_nQ8ad3*xAmt~2@d=h%Htk3erESw?&4%20u!-*r@-$<*?o*VKpZoAhj$euB zW)AaHwdm#}yFXEuyFR*WX7ZzXmd2ld@!0T>d|L!GPttLxL3w-nwpXHfnPq2H}97>hkedn#X zu1&4tGc(=d`4^9K{=y|XJ)@>7M9EgW!-=yS2rJ~4gLCu;J*sZT&3mdydx{5cS>X8D zH5Mj4>a{u>{SIN^(5e_6=QG!Lhzr5x^@R67wuyk}H2en;xj-vfM#tx8>nEmKwJb`E z1aV#%R_uY9lJi&j(6e9o^v7TSZLfc)?N%7}wh=<$c@DK|NN+Iwoz3meM}6PB;br#6 zouUx5DvJ3@i?w$7&3OYI*=4gZAxU!sS11)ME!3%n4uiP-zD_jw-}f)pkL{WfoVwKG z-q#%9_4nV6ZF@ZN>`Rd`PDFp}j?`3&;m2SV!J$K&9w|?uxJoC&m z*tW&Y%p{BRvz$78hGCRa@gz!>6on$sVR6dl&STSLc?(MjcI{FOMITsD$ls5-}6Bj-uP%Z#F7Ff1%u&;W4GOf=Q|vK;dyM=_<9KpdsL&5LKFHnu3OOU zjW~C4g}d%NYFu^7l#TW>FP+_DrCpqmk}oTzuDGt94&&U)%Q6Da0bk}T1iFQM^&#pT z0~p6~ZrmOkLsp#fQ@Y0Zkc-#&mCt_sx4!?c{q4Vbcy8%1c~MgLEP8{{&h^#y$6VLr zRon1CVR=bEg2hRTjgH0gwh`<6ngi%fcliHPGnQqUPo8HB3I$27Z~U@_Q22$OwJq`f zyeMy)s@a^n)J9V9XWsc*9(?1&TsU`*UVp&G_6|vsa`f2ET)T3S$>~}4@7u?zQ>Q4( z995KKL%Shc+dZCn;WS@({6$t*mYHgVSW>{IPuB_9LJ{D z8?fE(6Z#?r^*N>Z2f)saV(qIfE3|pQu08%18fIp={Qh72?Wf=VuJ1T};K*(Go{!@c z^m@@_E9=Q)!B_d%q!cVPVYu{hy3C%ypKJ54{+|%3RkbKbLhY>;w0gjXHU=@j(Fg8_j2a!75amSW<5kx^7nq` z@AC9>uTTpOwYgA|Hj1rY{u6_k-|59V-0bVcr78Zujw>Ea(zLO*-Zorxt*|Yzrb_+Z zF?=E&KL-7^JPSPzz_7yayA?(w{>^Xx%D?;8zxsdttJ!L;M$*|~JB?5Jo^e?IYJZw# znWgO4C55@J!#CnlN-b2v;Ip-AxVO`bjhctF@MGBSX1xDHpQFeXu509T9b3}rbor^j z^Rq0kjSxcf>~klWnQovo^!gFKLCp4cmr5Y0RUNc4@CetnNpkEHO!3#Z=o17~NaXX>2a zNYB`^{O6N3f&K=0PPSIxknw}oVo>DjRGMXbl{SJ=Ns_R>9U-N~?)e&}GN0TeRrCf4 zJG~ewVQ$*PbtH4M(}aOX9FJJv8f*uyU7eq+`%x5;=NXO^XjKw=?%%p?zyF*d=iG8S z_L~=I&F6&Rb6-(xXsz|v`TtYm=nW1pmSlzVU(_|etAFN0|MCC%_@zs|R=ck|wk0~> zNYBY04&!go0MdiR%6@n-jQ@I^6}O|sYMPawD@t81O?iKHezx%~Ynxq!5`?~mBLlkQ zR8^KGrGhL=je19!)2KQ>QLDH=>^bsb<=c!#8A-0FRV!%Jdb=BQaKWW0VP!k}!?MhO zujwha-eR(8Bc$_(6~B+a_|n-+gC2K|67>(n*Q0RxdIpe`@*6dPaBO==!%q)Kg=O0k zDMe{Xfk2%};`rszv#r4>Lr96|3R*R982C;w7!?-UV2jHl!?h)qz`atG#m6=3Vc)gL zQh{&`qqWF6BBdb9OAamCRD2^o?t1?trteTFiy|}pvOl!&wXDA}Su1{Ql!)IE-(b(_ z8#8)teC$noVp)_@Qfe&#f$uqI1^QnGzGEmeLL-H~W?ACTNU6`&DjuP4V~diq%&;7R zl*(~z@c~s99kfQG4d_Z}v@T}^3ayQr#^R(!J+vsw|6!jNv^JvTf7nY(@pw_{zoOMx zGWZ+d^}j>_{l4F^(geQC;XNU)qZFto>Q#@xw;CEaUoD0G*M^Tmg3{`VVHEwOL>;dN&cCuO`AeXX zQtjB5!j_7OhEFAMP-XcMThR4vfn)KnwaPEMjzsAa&yiS?)B469&b*WX!2mx0Dyal6 z{@CaJPtP|=0DVPIlnKN?*62Gl{B@uU3ayJDPm}mc97ptfeX=Zl-uIl}D7B{I+f;nJ zlNb4q_@2G#T7oRkw@am-w=6S*`N|hU literal 0 HcmV?d00001 diff --git a/packaging/install-dependencies.sh b/packaging/install-dependencies.sh new file mode 100755 index 0000000..320744f --- /dev/null +++ b/packaging/install-dependencies.sh @@ -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 diff --git a/packaging/pristontale-eu b/packaging/pristontale-eu new file mode 100755 index 0000000..574356f --- /dev/null +++ b/packaging/pristontale-eu @@ -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 "$@" diff --git a/ptdiag/__init__.py b/ptdiag/__init__.py new file mode 100644 index 0000000..b919f76 --- /dev/null +++ b/ptdiag/__init__.py @@ -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}") diff --git a/ptdiag/__main__.py b/ptdiag/__main__.py new file mode 100644 index 0000000..87f6703 --- /dev/null +++ b/ptdiag/__main__.py @@ -0,0 +1,6 @@ +from .cli import main + + +if __name__ == "__main__": + main() + diff --git a/ptdiag/assbin.py b/ptdiag/assbin.py new file mode 100644 index 0000000..df129e0 --- /dev/null +++ b/ptdiag/assbin.py @@ -0,0 +1,1081 @@ +"""Inspect the XOR-wrapped Assimp binary scene caches used by PristonTale EU.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import io +from pathlib import Path +import shutil +import struct +from typing import BinaryIO, Protocol +import zlib + + +ASSBIN_HEADER_LENGTH = 512 +ASSBIN_CHUNK_AITEXTURE = 0x1236 +ASSBIN_CHUNK_AIMESH = 0x1237 +ASSBIN_CHUNK_AISCENE = 0x1239 +ASSBIN_CHUNK_AIANIMATION = 0x123B +ASSBIN_CHUNK_AINODE = 0x123C +ASSBIN_CHUNK_AIMATERIAL = 0x123D +ASSBIN_CHUNK_AIMATERIALPROPERTY = 0x123E + +PROPERTY_TYPE_NAMES = { + 1: "float", + 2: "double", + 3: "string", + 4: "integer", + 5: "buffer", +} + +MAX_STRING_LENGTH = 16 * 1024 * 1024 +MAX_CAPTURE_LENGTH = 16 * 1024 * 1024 +READ_CHUNK_SIZE = 1024 * 1024 + + +class AssbinError(ValueError): + """Raised when a wrapped Assbin cache is invalid or unsupported.""" + + +class Reader(Protocol): + def tell(self) -> int: ... + + def seek(self, offset: int) -> int: ... + + def read_exact(self, size: int) -> bytes: ... + + @property + def size(self) -> int: ... + + +class XorFileReader: + """Read a file while undoing the position-dependent game wrapper.""" + + def __init__(self, stream: BinaryIO) -> None: + self._stream = stream + position = stream.tell() + stream.seek(0, io.SEEK_END) + self._size = stream.tell() + stream.seek(position) + + @property + def size(self) -> int: + return self._size + + def tell(self) -> int: + return self._stream.tell() + + def seek(self, offset: int) -> int: + if offset < 0 or offset > self._size: + raise AssbinError(f"seek outside wrapped scene: {offset}") + return self._stream.seek(offset) + + def read_exact(self, size: int) -> bytes: + if size < 0: + raise AssbinError(f"negative read size: {size}") + offset = self.tell() + raw = self._stream.read(size) + if len(raw) != size: + raise AssbinError( + f"unexpected EOF at {offset}: wanted {size} bytes, got {len(raw)}" + ) + return xor_bytes(raw, offset) + + +class BytesReader: + """Read an already-decoded byte buffer.""" + + def __init__(self, data: bytes) -> None: + self._stream = io.BytesIO(data) + self._size = len(data) + + @property + def size(self) -> int: + return self._size + + def tell(self) -> int: + return self._stream.tell() + + def seek(self, offset: int) -> int: + if offset < 0 or offset > self._size: + raise AssbinError(f"seek outside decoded scene: {offset}") + return self._stream.seek(offset) + + def read_exact(self, size: int) -> bytes: + offset = self.tell() + data = self._stream.read(size) + if len(data) != size: + raise AssbinError( + f"unexpected EOF at {offset}: wanted {size} bytes, got {len(data)}" + ) + return data + + +@dataclass(frozen=True) +class AssbinHeader: + signature: str + version_major: int + version_minor: int + version_revision: int + compile_flags: int + shortened: bool + compressed: bool + source_file: str + options: str + + +@dataclass(frozen=True) +class Chunk: + identifier: int + header_offset: int + data_offset: int + data_size: int + end_offset: int + + +@dataclass(frozen=True) +class BlobSummary: + length: int + sha256: str + prefix_hex: str + signature: str | None + value: object | None + png: PngMetadata | None + + +@dataclass(frozen=True) +class PngMetadata: + valid: bool + errors: list[str] + width: int | None + height: int | None + bit_depth: int | None + color_type: int | None + compression_method: int | None + filter_method: int | None + interlace_method: int | None + chunks: list[str] + inflated_length: int | None + + +@dataclass(frozen=True) +class MaterialProperty: + key: str + semantic: int + index: int + data_length: int + property_type: int + property_type_name: str + data_offset: int + sha256: str + prefix_hex: str + signature: str | None + value: object | None + + +@dataclass(frozen=True) +class Material: + index: int + name: str | None + properties: list[MaterialProperty] + + +@dataclass(frozen=True) +class EmbeddedTexture: + index: int + chunk_header_offset: int + chunk_data_size: int + width: int + height: int + format_hint: str + data_offset: int + data_length: int + sha256: str + prefix_hex: str + signature: str | None + png: PngMetadata | None + + +@dataclass(frozen=True) +class SceneCounts: + flags: int + meshes: int + materials: int + animations: int + textures: int + lights: int + cameras: int + + +@dataclass(frozen=True) +class SceneInspection: + path: str + file_size: int + file_sha256: str + header: AssbinHeader + counts: SceneCounts + materials: list[Material] + embedded_textures: list[EmbeddedTexture] + scene_chunk_offset: int + scene_chunk_size: int + parsed_scene_offset: int + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def xor_bytes(data: bytes, absolute_offset: int = 0) -> bytes: + """Undo the repeating position-dependent XOR wrapper.""" + + return bytes( + value ^ ((0x5C + absolute_offset + index) & 0xFF) + for index, value in enumerate(data) + ) + + +def inspect_wrapped_assbin(path: Path) -> SceneInspection: + """Parse scene metadata and material properties without changing the cache.""" + + resolved_path = path.expanduser().resolve() + file_sha256 = _hash_path(resolved_path) + with resolved_path.open("rb") as stream: + wrapped_reader = XorFileReader(stream) + header = _read_header(wrapped_reader) + if header.shortened: + raise AssbinError("shortened Assbin regression dumps are unsupported") + + if header.compressed: + uncompressed_size = _read_u32(wrapped_reader) + compressed_data = wrapped_reader.read_exact( + wrapped_reader.size - wrapped_reader.tell() + ) + try: + decoded = zlib.decompress(compressed_data) + except zlib.error as exc: + raise AssbinError(f"zlib decompression failed: {exc}") from exc + if len(decoded) != uncompressed_size: + raise AssbinError( + "decompressed size mismatch: " + f"expected {uncompressed_size}, got {len(decoded)}" + ) + reader: Reader = BytesReader(decoded) + else: + reader = wrapped_reader + + counts, materials, textures, scene_chunk, parsed_offset = _read_scene(reader) + return SceneInspection( + path=str(resolved_path), + file_size=wrapped_reader.size, + file_sha256=file_sha256, + header=header, + counts=counts, + materials=materials, + embedded_textures=textures, + scene_chunk_offset=scene_chunk.header_offset, + scene_chunk_size=scene_chunk.data_size, + parsed_scene_offset=parsed_offset, + ) + + +def extract_embedded_texture( + path: Path, + inspection: SceneInspection, + texture_index: int, + output: Path, +) -> dict[str, object]: + """Extract one decoded embedded texture from an uncompressed wrapped cache.""" + + if inspection.header.compressed: + raise AssbinError("direct extraction from compressed Assbin caches is unsupported") + if texture_index < 0 or texture_index >= len(inspection.embedded_textures): + raise AssbinError( + f"texture index {texture_index} is outside 0.." + f"{len(inspection.embedded_textures) - 1}" + ) + texture = inspection.embedded_textures[texture_index] + resolved_path = path.expanduser().resolve() + resolved_output = output.expanduser().resolve() + resolved_output.parent.mkdir(parents=True, exist_ok=True) + + digest = hashlib.sha256() + written = 0 + try: + with resolved_path.open("rb") as source, resolved_output.open("xb") as target: + reader = XorFileReader(source) + reader.seek(texture.data_offset) + remaining = texture.data_length + while remaining: + part = reader.read_exact(min(remaining, READ_CHUNK_SIZE)) + target.write(part) + digest.update(part) + written += len(part) + remaining -= len(part) + except BaseException: + resolved_output.unlink(missing_ok=True) + raise + + actual_sha256 = digest.hexdigest() + if written != texture.data_length or actual_sha256 != texture.sha256: + resolved_output.unlink(missing_ok=True) + raise AssbinError( + "extracted texture verification failed: " + f"size {written}/{texture.data_length}, " + f"sha256 {actual_sha256}/{texture.sha256}" + ) + return { + "index": texture_index, + "output": str(resolved_output), + "size": written, + "sha256": actual_sha256, + "format_hint": texture.format_hint, + "signature": texture.signature, + } + + +@dataclass(frozen=True) +class _BinaryPatch: + offset: int + old_length: int + data: bytes + description: str + + +def normalize_wrapped_assbin_textures( + source: Path, + output: Path, + texture_indices: tuple[int, ...], +) -> dict[str, object]: + """Convert selected embedded 16-bit RGB PNGs to 8-bit in a new cache.""" + + if not texture_indices: + raise AssbinError("at least one texture index is required") + if len(set(texture_indices)) != len(texture_indices): + raise AssbinError("texture indices must be unique") + resolved_source = source.expanduser().resolve(strict=True) + resolved_output = output.expanduser().resolve() + if resolved_source == resolved_output: + raise AssbinError("normalization output must differ from its source") + if resolved_output.exists() or resolved_output.is_symlink(): + raise AssbinError(f"normalization output already exists: {resolved_output}") + + inspection = inspect_wrapped_assbin(resolved_source) + if inspection.header.compressed: + raise AssbinError("normalizing compressed Assbin caches is unsupported") + selected = [] + for index in sorted(texture_indices): + if index < 0 or index >= len(inspection.embedded_textures): + raise AssbinError( + f"texture index {index} is outside 0.." + f"{len(inspection.embedded_textures) - 1}" + ) + selected.append(inspection.embedded_textures[index]) + + patches: list[_BinaryPatch] = [] + changes: list[dict[str, object]] = [] + total_delta = 0 + with resolved_source.open("rb") as stream: + reader = XorFileReader(stream) + for texture in selected: + reader.seek(texture.data_offset) + old_png = reader.read_exact(texture.data_length) + new_png = normalize_png_16_rgb_to_8(old_png) + new_metadata = _inspect_png(new_png) + new_sha256 = hashlib.sha256(new_png).hexdigest() + delta = len(new_png) - texture.data_length + total_delta += delta + patches.extend( + ( + _BinaryPatch( + texture.chunk_header_offset + 4, + 4, + struct.pack(" bytes: + """Losslessly parse a 16-bit RGB PNG and downsample its channels to 8-bit.""" + + metadata = _inspect_png(data) + if not metadata.valid: + raise AssbinError(f"cannot normalize invalid PNG: {'; '.join(metadata.errors)}") + if ( + metadata.width is None + or metadata.height is None + or metadata.bit_depth != 16 + or metadata.color_type != 2 + or metadata.interlace_method != 0 + ): + raise AssbinError( + "normalization requires a non-interlaced 16-bit RGB PNG; got " + f"{metadata.width}x{metadata.height}, bit depth {metadata.bit_depth}, " + f"color type {metadata.color_type}, interlace {metadata.interlace_method}" + ) + + chunks = _png_chunks(data) + compressed = b"".join(chunk_data for name, chunk_data in chunks if name == b"IDAT") + try: + filtered = zlib.decompress(compressed) + except zlib.error as exc: + raise AssbinError(f"PNG IDAT decompression failed: {exc}") from exc + row_bytes = metadata.width * 6 + expected_length = (row_bytes + 1) * metadata.height + if len(filtered) != expected_length: + raise AssbinError( + f"unexpected 16-bit RGB scanline length: {len(filtered)} != {expected_length}" + ) + + converted = bytearray() + previous = bytes(row_bytes) + offset = 0 + for _ in range(metadata.height): + filter_type = filtered[offset] + row = bytearray(filtered[offset + 1 : offset + 1 + row_bytes]) + _unfilter_png_row(row, previous, filter_type, 6) + converted.append(0) + for byte_index in range(0, row_bytes, 2): + sample = (row[byte_index] << 8) | row[byte_index + 1] + converted.append((sample + 128) // 257) + previous = bytes(row) + offset += row_bytes + 1 + + new_idat = zlib.compress(bytes(converted), level=9) + result = bytearray(b"\x89PNG\r\n\x1a\n") + wrote_idat = False + for name, chunk_data in chunks: + if name == b"IHDR": + mutable_header = bytearray(chunk_data) + mutable_header[8] = 8 + result.extend(_make_png_chunk(name, bytes(mutable_header))) + elif name == b"IDAT": + if not wrote_idat: + result.extend(_make_png_chunk(b"IDAT", new_idat)) + wrote_idat = True + else: + result.extend(_make_png_chunk(name, chunk_data)) + normalized = bytes(result) + new_metadata = _inspect_png(normalized) + if not new_metadata.valid or new_metadata.bit_depth != 8: + raise AssbinError( + f"normalized PNG failed verification: {'; '.join(new_metadata.errors)}" + ) + return normalized + + +def _read_header(reader: Reader) -> AssbinHeader: + if reader.size < ASSBIN_HEADER_LENGTH: + raise AssbinError( + f"wrapped scene is smaller than the {ASSBIN_HEADER_LENGTH}-byte header" + ) + reader.seek(0) + signature_raw = reader.read_exact(44) + if not signature_raw.startswith(b"ASSIMP.binary-dump."): + raise AssbinError( + f"decoded header has no Assbin signature: {signature_raw[:20]!r}" + ) + version_major = _read_u32(reader) + version_minor = _read_u32(reader) + version_revision = _read_u32(reader) + compile_flags = _read_u32(reader) + shortened = _read_u16(reader) > 0 + compressed = _read_u16(reader) > 0 + source_file = _decode_fixed(reader.read_exact(256)) + options = _decode_fixed(reader.read_exact(128)) + reader.read_exact(64) + if reader.tell() != ASSBIN_HEADER_LENGTH: + raise AssbinError(f"internal header-size error: {reader.tell()}") + return AssbinHeader( + signature=signature_raw.rstrip(b"\x00\r\n").decode("utf-8", "replace"), + version_major=version_major, + version_minor=version_minor, + version_revision=version_revision, + compile_flags=compile_flags, + shortened=shortened, + compressed=compressed, + source_file=source_file, + options=options, + ) + + +def _read_scene( + reader: Reader, +) -> tuple[SceneCounts, list[Material], list[EmbeddedTexture], Chunk, int]: + scene_chunk = _read_chunk(reader, ASSBIN_CHUNK_AISCENE) + counts = SceneCounts( + flags=_read_u32(reader), + meshes=_read_u32(reader), + materials=_read_u32(reader), + animations=_read_u32(reader), + textures=_read_u32(reader), + lights=_read_u32(reader), + cameras=_read_u32(reader), + ) + + _skip_chunk(reader, ASSBIN_CHUNK_AINODE, scene_chunk.end_offset) + for _ in range(counts.meshes): + _skip_chunk(reader, ASSBIN_CHUNK_AIMESH, scene_chunk.end_offset) + + materials = [ + _read_material(reader, index, scene_chunk.end_offset) + for index in range(counts.materials) + ] + + for _ in range(counts.animations): + _skip_chunk(reader, ASSBIN_CHUNK_AIANIMATION, scene_chunk.end_offset) + + textures = [ + _read_texture(reader, index, scene_chunk.end_offset) + for index in range(counts.textures) + ] + + # Light and camera layouts are irrelevant to texture diagnosis. Their complete + # chunk sizes let us advance safely without depending on their struct versions. + for _ in range(counts.lights + counts.cameras): + _skip_unknown_chunk(reader, scene_chunk.end_offset) + + parsed_offset = reader.tell() + if parsed_offset > scene_chunk.end_offset: + raise AssbinError( + f"scene parser crossed chunk boundary: {parsed_offset} > {scene_chunk.end_offset}" + ) + return counts, materials, textures, scene_chunk, parsed_offset + + +def _read_material(reader: Reader, index: int, parent_end: int) -> Material: + chunk = _read_chunk(reader, ASSBIN_CHUNK_AIMATERIAL, parent_end) + property_count = _read_u32(reader) + properties = [ + _read_material_property(reader, chunk.end_offset) + for _ in range(property_count) + ] + _assert_not_past(reader, chunk) + reader.seek(chunk.end_offset) + name = _material_name(properties) + return Material(index=index, name=name, properties=properties) + + +def _read_material_property(reader: Reader, parent_end: int) -> MaterialProperty: + chunk = _read_chunk(reader, ASSBIN_CHUNK_AIMATERIALPROPERTY, parent_end) + key = _read_string(reader) + semantic = _read_u32(reader) + index = _read_u32(reader) + data_length = _read_u32(reader) + property_type = _read_u32(reader) + data_offset = reader.tell() + if data_offset + data_length > chunk.end_offset: + raise AssbinError( + f"material property {key!r} data exceeds its chunk: " + f"{data_offset} + {data_length} > {chunk.end_offset}" + ) + blob = _summarize_blob(reader, data_length, property_type) + _assert_not_past(reader, chunk) + reader.seek(chunk.end_offset) + return MaterialProperty( + key=key, + semantic=semantic, + index=index, + data_length=data_length, + property_type=property_type, + property_type_name=PROPERTY_TYPE_NAMES.get(property_type, "unknown"), + data_offset=data_offset, + sha256=blob.sha256, + prefix_hex=blob.prefix_hex, + signature=blob.signature, + value=blob.value, + ) + + +def _read_texture(reader: Reader, index: int, parent_end: int) -> EmbeddedTexture: + chunk = _read_chunk(reader, ASSBIN_CHUNK_AITEXTURE, parent_end) + width = _read_u32(reader) + height = _read_u32(reader) + format_hint = _decode_fixed(reader.read_exact(8)) + data_length = width if height == 0 else width * height * 4 + data_offset = reader.tell() + if data_offset + data_length > chunk.end_offset: + raise AssbinError( + f"embedded texture {index} data exceeds its chunk boundary" + ) + blob = _summarize_blob(reader, data_length, 5) + _assert_not_past(reader, chunk) + reader.seek(chunk.end_offset) + return EmbeddedTexture( + index=index, + chunk_header_offset=chunk.header_offset, + chunk_data_size=chunk.data_size, + width=width, + height=height, + format_hint=format_hint, + data_offset=data_offset, + data_length=data_length, + sha256=blob.sha256, + prefix_hex=blob.prefix_hex, + signature=blob.signature, + png=blob.png, + ) + + +def _summarize_blob(reader: Reader, length: int, property_type: int) -> BlobSummary: + remaining = length + digest = hashlib.sha256() + prefix = bytearray() + captured = bytearray() + while remaining: + part = reader.read_exact(min(remaining, READ_CHUNK_SIZE)) + digest.update(part) + if len(prefix) < 64: + prefix.extend(part[: 64 - len(prefix)]) + if length <= MAX_CAPTURE_LENGTH: + captured.extend(part) + remaining -= len(part) + data = bytes(captured) if length <= MAX_CAPTURE_LENGTH else bytes(prefix) + signature = _identify_signature(bytes(prefix)) + return BlobSummary( + length=length, + sha256=digest.hexdigest(), + prefix_hex=bytes(prefix).hex(), + signature=signature, + value=_decode_property_value(data, length, property_type), + png=_inspect_png(data) if signature == "PNG" and len(data) == length else None, + ) + + +def _decode_property_value(data: bytes, full_length: int, property_type: int) -> object | None: + if full_length > MAX_CAPTURE_LENGTH: + return None + if property_type == 3 and len(data) >= 4: + string_length = struct.unpack_from(" str | None: + signatures = ( + (b"\x89PNG\r\n\x1a\n", "PNG"), + (b"\xff\xd8\xff", "JPEG"), + (b"BM", "BMP"), + (b"DDS ", "DDS"), + (b"GIF87a", "GIF87a"), + (b"GIF89a", "GIF89a"), + (b"RIFF", "RIFF"), + (b"KTX 11\xbb\r\n\x1a\n", "KTX"), + ) + for magic, name in signatures: + if prefix.startswith(magic): + return name + return None + + +def _inspect_png(data: bytes) -> PngMetadata: + errors: list[str] = [] + chunks: list[str] = [] + width: int | None = None + height: int | None = None + bit_depth: int | None = None + color_type: int | None = None + compression_method: int | None = None + filter_method: int | None = None + interlace_method: int | None = None + idat_parts: list[bytes] = [] + inflated_length: int | None = None + offset = 8 + saw_iend = False + + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + errors.append("invalid PNG signature") + else: + while offset < len(data): + if offset + 12 > len(data): + errors.append(f"truncated PNG chunk header at {offset}") + break + chunk_length = struct.unpack_from(">I", data, offset)[0] + chunk_type = data[offset + 4 : offset + 8] + data_start = offset + 8 + data_end = data_start + chunk_length + chunk_end = data_end + 4 + if chunk_end > len(data): + errors.append( + f"PNG chunk {chunk_type!r} at {offset} exceeds data length" + ) + break + chunk_data = data[data_start:data_end] + stored_crc = struct.unpack_from(">I", data, data_end)[0] + calculated_crc = zlib.crc32(chunk_type) + calculated_crc = zlib.crc32(chunk_data, calculated_crc) & 0xFFFFFFFF + name = chunk_type.decode("ascii", "replace") + chunks.append(name) + if stored_crc != calculated_crc: + errors.append( + f"CRC mismatch in {name} chunk at {offset}: " + f"0x{stored_crc:08x} != 0x{calculated_crc:08x}" + ) + + if chunk_type == b"IHDR": + if chunk_length != 13: + errors.append(f"IHDR length is {chunk_length}, expected 13") + else: + ( + width, + height, + bit_depth, + color_type, + compression_method, + filter_method, + interlace_method, + ) = struct.unpack(">IIBBBBB", chunk_data) + elif chunk_type == b"IDAT": + idat_parts.append(chunk_data) + elif chunk_type == b"IEND": + saw_iend = True + if chunk_length != 0: + errors.append("IEND chunk is not empty") + offset = chunk_end + break + offset = chunk_end + + if not chunks or chunks[0] != "IHDR": + errors.append("IHDR is missing or is not the first chunk") + if not idat_parts: + errors.append("IDAT is missing") + else: + try: + inflated_length = len(zlib.decompress(b"".join(idat_parts))) + except zlib.error as exc: + errors.append(f"IDAT zlib stream is invalid: {exc}") + if not saw_iend: + errors.append("IEND is missing") + elif offset != len(data): + errors.append(f"{len(data) - offset} trailing bytes after IEND") + + return PngMetadata( + valid=not errors, + errors=errors, + width=width, + height=height, + bit_depth=bit_depth, + color_type=color_type, + compression_method=compression_method, + filter_method=filter_method, + interlace_method=interlace_method, + chunks=chunks, + inflated_length=inflated_length, + ) + + +def _png_chunks(data: bytes) -> list[tuple[bytes, bytes]]: + chunks: list[tuple[bytes, bytes]] = [] + offset = 8 + while offset < len(data): + if offset + 12 > len(data): + raise AssbinError(f"truncated PNG chunk at {offset}") + length = struct.unpack_from(">I", data, offset)[0] + name = data[offset + 4 : offset + 8] + data_start = offset + 8 + data_end = data_start + length + chunk_end = data_end + 4 + if chunk_end > len(data): + raise AssbinError(f"PNG chunk {name!r} exceeds data length") + chunks.append((name, data[data_start:data_end])) + offset = chunk_end + if name == b"IEND": + break + if offset != len(data): + raise AssbinError(f"PNG has {len(data) - offset} trailing bytes") + return chunks + + +def _png_scanlines_sha256(data: bytes) -> str: + """Hash decoded PNG scanlines independently of the zlib encoder bytes.""" + + compressed = b"".join( + chunk_data + for name, chunk_data in _png_chunks(data) + if name == b"IDAT" + ) + try: + scanlines = zlib.decompress(compressed) + except zlib.error as exc: + raise AssbinError(f"PNG IDAT decompression failed: {exc}") from exc + return hashlib.sha256(scanlines).hexdigest() + + +def _make_png_chunk(name: bytes, data: bytes) -> bytes: + crc = zlib.crc32(name) + crc = zlib.crc32(data, crc) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + name + data + struct.pack(">I", crc) + + +def _unfilter_png_row( + row: bytearray, + previous: bytes, + filter_type: int, + bytes_per_pixel: int, +) -> None: + if filter_type == 0: + return + if filter_type not in {1, 2, 3, 4}: + raise AssbinError(f"unsupported PNG filter type: {filter_type}") + for index in range(len(row)): + left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + up = previous[index] + upper_left = previous[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + if filter_type == 1: + predictor = left + elif filter_type == 2: + predictor = up + elif filter_type == 3: + predictor = (left + up) // 2 + else: + predictor = _paeth_predictor(left, up, upper_left) + row[index] = (row[index] + predictor) & 0xFF + + +def _paeth_predictor(left: int, up: int, upper_left: int) -> int: + prediction = left + up - upper_left + left_distance = abs(prediction - left) + up_distance = abs(prediction - up) + upper_left_distance = abs(prediction - upper_left) + if left_distance <= up_distance and left_distance <= upper_left_distance: + return left + if up_distance <= upper_left_distance: + return up + return upper_left + + +def _rewrite_wrapped_file( + source: Path, + output: Path, + patches: list[_BinaryPatch], +) -> tuple[str, int]: + ordered = sorted(patches, key=lambda patch: patch.offset) + previous_end = 0 + for patch in ordered: + if patch.offset < previous_end: + raise AssbinError( + f"overlapping binary patch at {patch.offset}: {patch.description}" + ) + previous_end = patch.offset + patch.old_length + + digest = hashlib.sha256() + output_offset = 0 + try: + with source.open("rb") as source_stream, output.open("xb") as output_stream: + reader = XorFileReader(source_stream) + for patch in ordered: + copy_length = patch.offset - reader.tell() + output_offset = _copy_rewrapped( + reader, + output_stream, + digest, + copy_length, + output_offset, + ) + reader.seek(patch.offset + patch.old_length) + wrapped_patch = xor_bytes(patch.data, output_offset) + output_stream.write(wrapped_patch) + digest.update(wrapped_patch) + output_offset += len(patch.data) + output_offset = _copy_rewrapped( + reader, + output_stream, + digest, + reader.size - reader.tell(), + output_offset, + ) + except BaseException: + output.unlink(missing_ok=True) + raise + return digest.hexdigest(), output_offset + + +def _copy_rewrapped( + reader: Reader, + output: BinaryIO, + digest: object, + length: int, + output_offset: int, +) -> int: + remaining = length + while remaining: + decoded = reader.read_exact(min(remaining, READ_CHUNK_SIZE)) + wrapped = xor_bytes(decoded, output_offset) + output.write(wrapped) + digest.update(wrapped) # type: ignore[attr-defined] + output_offset += len(wrapped) + remaining -= len(decoded) + return output_offset + + +def _verify_normalization( + source: SceneInspection, + output: SceneInspection, + selected_indices: set[int], +) -> None: + if source.counts != output.counts: + raise AssbinError("normalized scene counts differ from source") + if len(source.embedded_textures) != len(output.embedded_textures): + raise AssbinError("normalized embedded texture count differs from source") + for before, after in zip(source.embedded_textures, output.embedded_textures): + if before.index in selected_indices: + if after.png is None or not after.png.valid or after.png.bit_depth != 8: + raise AssbinError( + f"normalized texture {before.index} is not a valid 8-bit PNG" + ) + elif before.sha256 != after.sha256 or before.data_length != after.data_length: + raise AssbinError(f"unselected texture {before.index} changed") + + +def _material_name(properties: list[MaterialProperty]) -> str | None: + for prop in properties: + if prop.key in {"?mat.name", "$mat.name"} and isinstance(prop.value, str): + return prop.value + return None + + +def _read_chunk(reader: Reader, expected: int, parent_end: int | None = None) -> Chunk: + header_offset = reader.tell() + identifier = _read_u32(reader) + data_size = _read_u32(reader) + data_offset = reader.tell() + end_offset = data_offset + data_size + limit = reader.size if parent_end is None else min(reader.size, parent_end) + if identifier != expected: + raise AssbinError( + f"unexpected chunk at {header_offset}: expected 0x{expected:04x}, " + f"got 0x{identifier:04x}" + ) + if end_offset > limit: + raise AssbinError( + f"chunk 0x{identifier:04x} at {header_offset} ends outside parent: " + f"{end_offset} > {limit}" + ) + return Chunk(identifier, header_offset, data_offset, data_size, end_offset) + + +def _skip_chunk(reader: Reader, expected: int, parent_end: int) -> None: + chunk = _read_chunk(reader, expected, parent_end) + reader.seek(chunk.end_offset) + + +def _skip_unknown_chunk(reader: Reader, parent_end: int) -> None: + header_offset = reader.tell() + identifier = _read_u32(reader) + data_size = _read_u32(reader) + end_offset = reader.tell() + data_size + if end_offset > parent_end or end_offset > reader.size: + raise AssbinError( + f"chunk 0x{identifier:04x} at {header_offset} ends outside scene" + ) + reader.seek(end_offset) + + +def _assert_not_past(reader: Reader, chunk: Chunk) -> None: + if reader.tell() > chunk.end_offset: + raise AssbinError( + f"parser crossed chunk 0x{chunk.identifier:04x} boundary: " + f"{reader.tell()} > {chunk.end_offset}" + ) + + +def _read_string(reader: Reader) -> str: + length = _read_u32(reader) + if length > MAX_STRING_LENGTH: + raise AssbinError(f"Assbin string is unreasonably large: {length}") + return reader.read_exact(length).decode("utf-8", "replace") + + +def _read_u16(reader: Reader) -> int: + return struct.unpack(" int: + return struct.unpack(" str: + return data.split(b"\x00", 1)[0].decode("utf-8", "replace") + + +def _hash_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(READ_CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() diff --git a/ptdiag/cli.py b/ptdiag/cli.py new file mode 100644 index 0000000..2f63fe5 --- /dev/null +++ b/ptdiag/cli.py @@ -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") diff --git a/ptdiag/config.py b/ptdiag/config.py new file mode 100644 index 0000000..1c299b7 --- /dev/null +++ b/ptdiag/config.py @@ -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 diff --git a/ptdiag/experiment.py b/ptdiag/experiment.py new file mode 100644 index 0000000..29d6983 --- /dev/null +++ b/ptdiag/experiment.py @@ -0,0 +1,1812 @@ +"""Controlled Proton 7 runtime experiments and result generation.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import datetime +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import signal +import subprocess +import sys +import time +from typing import Any, Mapping, Sequence, TextIO + +from .config import AppConfig, project_root +from .filesystem import sha256_file, verify_known_game_hashes +from .logs import ( + LogFileState, + discover_current_native_logs, + parse_current_run_log, + read_current_run_log_text, + snapshot_native_logs, +) +from .ini import IniError, inspect_ini_value +from .performance import PerformanceSampler +from .prefix import ( + PrefixSafetyError, + cleanup_dedicated_wineserver, + validate_dedicated_prefix, +) +from .process import collect_environment +from .registry import ( + DesktopRegistrySnapshot, + desktop_registry_restored, + read_registry_string_values, + snapshot_desktop_registry, +) +from .result import ResultDirectory, create_result_directory, write_json +from .runner import CommandLogger, CommandResult +from .testcase import TestIniOverride, TestModification, Testcase +from .trace import process_trace_shards + + +_EXPLORER_KEY = r"HKCU\Software\Wine\Explorer" +_DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops" +_WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" +_DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable" +_PERSISTENT_STATE_CACHE_SENTINEL = "@ptdiag-persistent" +_PERSISTENT_STATE_CACHE_DIRECTORY = "ptdiag-cache/dxvk-state" + + +@dataclass(frozen=True) +class ExperimentResult: + result_directory: Path + summary: dict[str, Any] + exit_code: int + + +class ExperimentError(RuntimeError): + """Raised for a controlled experiment setup or lifecycle failure.""" + + +def run_experiment( + config: AppConfig, + testcase: Testcase, + *, + dry_run: bool, + results_root: Path | None = None, +) -> ExperimentResult: + if testcase.modification is not None: + return _run_modified_experiment( + config, + testcase, + dry_run=dry_run, + results_root=results_root, + ) + return _run_standard_experiment( + config, + testcase, + dry_run=dry_run, + results_root=results_root, + ) + + +def _run_standard_experiment( + config: AppConfig, + testcase: Testcase, + *, + dry_run: bool, + results_root: Path | None = None, + prepared_result: ResultDirectory | None = None, + started_at: datetime | None = None, +) -> ExperimentResult: + started_at = started_at or datetime.now().astimezone() + result = prepared_result or create_result_directory( + results_root or project_root() / "results", testcase.name, started_at + ) + logger = CommandLogger(result.commands_path) + (result.path / "runner.log").touch(exist_ok=True) + (result.path / "native.log").touch(exist_ok=True) + environment = collect_environment(config) + hashes = _collect_hashes(config, testcase) + write_json(result.path / "hashes.json", {"files": hashes}) + + desktop_name = _desktop_name(result.path) + metadata: dict[str, Any] = { + "test": testcase.name, + "description": testcase.description, + "started_at": started_at.isoformat(), + "result_directory": str(result.path), + "dry_run": dry_run, + "testcase": testcase.to_dict(), + "config_source": str(config.source), + "desktop_name": desktop_name, + "native_log_directory": str(config.paths.native_logs), + } + write_json(result.metadata_path, metadata) + + preflight_errors = _preflight(config, testcase, environment, hashes) + baseline = snapshot_native_logs(config.paths.native_logs) + metadata["old_log_baseline"] = _serialize_log_states(baseline) + write_json(result.metadata_path, metadata) + registry_before: DesktopRegistrySnapshot | None = None + try: + registry_before = snapshot_desktop_registry(config.proton7.prefix, desktop_name) + except OSError as exc: + preflight_errors.append(f"Could not snapshot dedicated-prefix registry: {exc}") + + launch_argv = _launch_argv(config, testcase, result.path) + env_overrides = _runtime_environment(config, testcase, result.path) + state_cache_summary = _state_cache_plan(config, env_overrides) + dll_overrides = ( + _collect_dll_overrides(config.proton7.prefix) + if testcase.capture.dll_overrides + else {} + ) + _write_text( + result.path / "environment.txt", + json.dumps( + { + "preflight": environment, + "runtime_overrides": dict(sorted(env_overrides.items())), + "dll_overrides": dll_overrides, + }, + indent=2, + sort_keys=True, + ), + ) + metadata["dll_overrides"] = dll_overrides + write_json(result.metadata_path, metadata) + summary: dict[str, Any] = { + "ok": False, + "status": "preflight-failed" if preflight_errors else "prepared", + "dry_run": dry_run, + "test": testcase.name, + "result_directory": str(result.path), + "preflight": { + "ok": not preflight_errors, + "errors": preflight_errors, + }, + "launch": { + "argv": launch_argv, + "cwd": str(config.paths.live_game), + "env_overrides": dict(sorted(env_overrides.items())), + "timeout_seconds": testcase.timeout_seconds, + }, + "native_log": { + "directory": str(config.paths.native_logs), + "baseline": _serialize_log_states(baseline), + "source": None, + "current_run_bytes": 0, + }, + "registry": { + "temporary_change": testcase.display.virtual_desktop, + "before": registry_before.to_dict() if registry_before else None, + "after": None, + "restored": None, + }, + "dxvk_state_cache": state_cache_summary, + } + + if preflight_errors: + _finalize(result, summary) + return ExperimentResult(result.path, summary, 2) + assert registry_before is not None + + if dry_run: + _dry_run_commands( + config, + testcase, + result, + logger, + registry_before, + launch_argv, + env_overrides, + ) + registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name) + summary["registry"]["after"] = registry_after.to_dict() + summary["registry"]["restored"] = desktop_registry_restored( + registry_before, registry_after + ) + summary["status"] = "dry-run" + summary["ok"] = True + summary["review_gate"] = ( + "Phase C dry-run complete; review artifacts before a live Game.exe launch." + ) + _finalize(result, summary) + return ExperimentResult(result.path, summary, 0) + + runtime_exit = 1 + setup_complete = False + try: + if state_cache_summary is not None: + state_cache_summary = _prepare_state_cache( + config, + env_overrides, + logger, + ) + summary["dxvk_state_cache"] = state_cache_summary + _cleanup_checked(config, logger) + if testcase.display.virtual_desktop: + _run_checked_commands( + logger, + _registry_setup_commands(config, desktop_name, testcase.display.size), + cwd=config.paths.live_game, + env_overrides=_proton_environment(config), + ) + setup_complete = True + baseline = snapshot_native_logs(config.paths.native_logs) + metadata["old_log_baseline"] = _serialize_log_states(baseline) + write_json(result.metadata_path, metadata) + runtime = _run_and_watch( + config, + testcase, + result, + logger, + launch_argv, + env_overrides, + baseline, + ) + summary["runtime"] = runtime + summary["native_log"].update(runtime.pop("native_log")) + runtime_exit = ( + 0 + if runtime.get("failure_detected") or runtime.get("scene_import_survived") + else 1 + ) + trigger_epoch = runtime.get("trigger_epoch") + if testcase.capture.strace_files and isinstance(trigger_epoch, (int, float)): + trace = process_trace_shards( + result.path / "raw-strace", + result.path, + trigger_epoch=float(trigger_epoch), + focus_before_seconds=testcase.capture.focus_before_seconds, + focus_after_seconds=testcase.capture.focus_after_seconds, + live_game=config.paths.live_game, + ) + summary["trace"] = trace.to_dict() + else: + summary["trace"] = None + if runtime.get("failure_detected"): + summary["status"] = "captured-scene-failure" + elif runtime.get("scene_import_survived"): + summary["status"] = "scene-import-survived" + else: + summary["status"] = "no-trigger" + summary["ok"] = bool( + runtime.get("failure_detected") or runtime.get("scene_import_survived") + ) + except ( + ExperimentError, + OSError, + ValueError, + PrefixSafetyError, + subprocess.SubprocessError, + ) as exc: + summary["status"] = "runner-error" + summary["error"] = str(exc) + summary["ok"] = False + runtime_exit = 2 + finally: + cleanup_error: str | None = None + try: + _cleanup_checked(config, logger) + except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc: + cleanup_error = str(exc) + if testcase.display.virtual_desktop and setup_complete: + try: + _run_checked_commands( + logger, + _registry_restore_commands(config, registry_before), + cwd=config.paths.live_game, + env_overrides=_proton_environment(config), + ) + except ExperimentError as exc: + cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc) + try: + registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name) + summary["registry"]["after"] = registry_after.to_dict() + summary["registry"]["restored"] = desktop_registry_restored( + registry_before, registry_after + ) + except OSError as exc: + cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc) + if cleanup_error: + summary["cleanup_error"] = cleanup_error + summary["ok"] = False + runtime_exit = 2 + if state_cache_summary is not None: + state_cache_summary["after"] = _state_cache_file_snapshot( + Path(str(state_cache_summary["cache_file"])) + ) + summary["dxvk_state_cache"] = state_cache_summary + + _finalize(result, summary) + return ExperimentResult(result.path, summary, runtime_exit) + + +def _run_modified_experiment( + config: AppConfig, + testcase: Testcase, + *, + dry_run: bool, + results_root: Path | None, +) -> ExperimentResult: + modification = testcase.modification + assert modification is not None + started_at = datetime.now().astimezone() + result = create_result_directory( + results_root or project_root() / "results", testcase.name, started_at + ) + (result.path / "runner.log").touch(exist_ok=True) + (result.path / "native.log").touch(exist_ok=True) + logger = CommandLogger(result.commands_path) + source_environment = collect_environment(config) + source_hashes = _collect_hashes(config, testcase) + source_errors = _preflight(config, testcase, source_environment, source_hashes) + write_json( + result.path / "source-preflight.json", + { + "environment": source_environment, + "hashes": source_hashes, + "ok": not source_errors, + "errors": source_errors, + }, + ) + disposable_game = ( + config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME / result.path.name + ).absolute() + modification_summary: dict[str, Any] = { + "kind": modification.kind, + "source": modification.source, + "destination": modification.destination, + "texture_indices": list(modification.texture_indices), + "ini_overrides": [ + { + "path": item.path, + "section": item.section, + "key": item.key, + "expected": item.expected, + "value": item.value, + } + for item in modification.ini_overrides + ], + "disposable_game": str(disposable_game), + "source_preflight_ok": not source_errors, + "prepared": False, + "prefix_link_restored": None, + "disposable_removed": None, + } + write_json(result.path / "modification.json", modification_summary) + if source_errors: + summary = _setup_failure_summary( + testcase, result, dry_run, source_errors, modification_summary + ) + _finalize(result, summary) + return ExperimentResult(result.path, summary, 2) + + try: + source, live_destination = _resource_copy_paths( + config.paths.live_game, modification + ) + ini_preflight = _inspect_ini_overrides( + config.paths.live_game, + modification.ini_overrides, + ) + for item in ini_preflight: + if not item["matches_expected"]: + raise ExperimentError( + f"INI preflight mismatch for [{item['section']}] {item['key']}: " + f"expected {item['expected']!r}, found {item['current']!r}" + ) + except (OSError, ValueError, ExperimentError) as exc: + summary = _setup_failure_summary( + testcase, result, dry_run, [str(exc)], modification_summary + ) + _finalize(result, summary) + return ExperimentResult(result.path, summary, 2) + + modification_summary.update( + { + "source_path": str(source), + "live_destination_path": str(live_destination), + "live_destination_existed_before": live_destination.exists(), + "source_sha256": sha256_file(source), + "source_size": source.stat().st_size, + "ini_preflight": ini_preflight, + } + ) + if dry_run: + _log_modification_plan( + logger, + config, + disposable_game, + source, + modification, + ) + outcome = _run_standard_experiment( + config, + testcase, + dry_run=True, + prepared_result=result, + started_at=started_at, + ) + modification_summary["status"] = "planned-only" + outcome.summary["modification"] = modification_summary + write_json(result.path / "modification.json", modification_summary) + _finalize(result, outcome.summary) + return outcome + + prefix_link = ( + config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU" + ) + original_link_target: str | None = None + link_changed = False + disposable_created = False + runtime_config = config + outcome: ExperimentResult | None = None + setup_error: str | None = None + cleanup_errors: list[str] = [] + try: + _cleanup_checked(config, logger) + preparation = _prepare_disposable_game( + config, + result, + logger, + modification, + disposable_game, + source, + ) + disposable_created = True + modification_summary.update(preparation) + original_link_target = _replace_game_link( + prefix_link, + disposable_game, + config.paths.live_game, + logger, + ) + link_changed = True + modification_summary.update( + { + "prefix_link": str(prefix_link), + "prefix_link_original_target": original_link_target, + "prefix_link_runtime_target": str(disposable_game), + } + ) + runtime_config = replace( + config, + paths=replace( + config.paths, + live_game=disposable_game, + native_logs=disposable_game / "save/logs", + ), + ) + modification_summary["prepared"] = True + write_json(result.path / "modification.json", modification_summary) + outcome = _run_standard_experiment( + runtime_config, + testcase, + dry_run=False, + prepared_result=result, + started_at=started_at, + ) + except ( + ExperimentError, + OSError, + ValueError, + PrefixSafetyError, + subprocess.SubprocessError, + ) as exc: + setup_error = str(exc) + finally: + try: + _cleanup_checked(runtime_config, logger) + except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc: + cleanup_errors.append(f"wineserver cleanup: {exc}") + if disposable_created and disposable_game.exists() and modification.ini_overrides: + try: + modification_summary["ini_runtime_final"] = _inspect_ini_overrides( + disposable_game, + modification.ini_overrides, + ) + except (ExperimentError, IniError, OSError) as exc: + cleanup_errors.append(f"final INI capture: {exc}") + if link_changed and original_link_target is not None: + try: + _restore_game_link( + prefix_link, + original_link_target, + config.paths.live_game, + logger, + ) + modification_summary["prefix_link_restored"] = True + except (ExperimentError, OSError) as exc: + modification_summary["prefix_link_restored"] = False + cleanup_errors.append(f"prefix link restore: {exc}") + if disposable_created or disposable_game.exists(): + try: + _remove_disposable_game(config, result, disposable_game, logger) + modification_summary["disposable_removed"] = True + except (ExperimentError, OSError) as exc: + modification_summary["disposable_removed"] = False + cleanup_errors.append(f"disposable cleanup: {exc}") + + if outcome is None: + errors = [setup_error or "Modified experiment did not start", *cleanup_errors] + summary = _setup_failure_summary( + testcase, result, False, errors, modification_summary + ) + exit_code = 2 + else: + summary = outcome.summary + exit_code = outcome.exit_code + if setup_error: + summary["status"] = "setup-error" + summary["error"] = setup_error + summary["ok"] = False + exit_code = 2 + if cleanup_errors: + summary["cleanup_error"] = "; ".join(cleanup_errors) + summary["ok"] = False + exit_code = 2 + summary["modification"] = modification_summary + write_json(result.path / "modification.json", modification_summary) + _finalize(result, summary) + return ExperimentResult(result.path, summary, exit_code) + + +def _resource_copy_paths( + live_game: Path, + modification: TestModification, +) -> tuple[Path, Path]: + if modification.kind not in {"copy-file", "normalize-assbin-textures"}: + raise ExperimentError(f"Unsupported modification kind: {modification.kind}") + root = live_game.resolve(strict=True) + source_relative = _safe_resource_relative_path(modification.source) + destination_relative = _safe_resource_relative_path(modification.destination) + source = root.joinpath(*source_relative.parts).resolve(strict=True) + try: + source.relative_to(root) + except ValueError as exc: + raise ExperimentError("Modification source escapes the live game tree") from exc + if not source.is_file(): + raise ExperimentError(f"Modification source is not a file: {source}") + destination = root.joinpath(*destination_relative.parts) + destination_parent = destination.parent.resolve(strict=True) + try: + destination_parent.relative_to(root) + except ValueError as exc: + raise ExperimentError("Modification destination escapes the live game tree") from exc + if modification.kind == "copy-file" and (destination.exists() or destination.is_symlink()): + raise ExperimentError( + f"Live modification destination already exists; refusing experiment: {destination}" + ) + if modification.kind == "normalize-assbin-textures": + if source_relative != destination_relative: + raise ExperimentError( + "Assbin normalization source and destination must be the same disposable file" + ) + if not modification.texture_indices: + raise ExperimentError("Assbin normalization requires texture_indices") + if not destination.is_file(): + raise ExperimentError(f"Assbin normalization target is not a file: {destination}") + return source, destination + + +def _safe_resource_relative_path(value: str) -> PurePosixPath: + if "\\" in value: + raise ExperimentError("Modification paths must use forward slashes") + path = PurePosixPath(value) + if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): + raise ExperimentError(f"Unsafe relative resource path: {value!r}") + return path + + +def _prepare_disposable_game( + config: AppConfig, + result: ResultDirectory, + logger: CommandLogger, + modification: TestModification, + disposable_game: Path, + source: Path, +) -> dict[str, Any]: + staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute() + _validate_disposable_path(staging_root, disposable_game, result.path.name) + if disposable_game.exists() or disposable_game.is_symlink(): + raise ExperimentError(f"Disposable game path already exists: {disposable_game}") + usage = shutil.disk_usage(staging_root.parent) + copy_bytes = _allocated_tree_bytes(config.paths.live_game) + if usage.free < copy_bytes + 2 * 1024**3: + raise ExperimentError( + f"Insufficient free space for disposable game copy: " + f"need at least {copy_bytes + 2 * 1024**3}, have {usage.free}" + ) + _require_command( + logger.run( + ["mkdir", "-p", "--", str(staging_root)], + timeout_seconds=15.0, + ), + "create disposable staging directory", + ) + _require_command( + logger.run( + [ + "cp", + "-a", + "--reflink=auto", + "--sparse=always", + f"{config.paths.live_game}/.", + str(disposable_game), + ], + timeout_seconds=900.0, + ), + "copy disposable game tree", + ) + destination_relative = _safe_resource_relative_path(modification.destination) + destination = disposable_game.joinpath(*destination_relative.parts) + if modification.kind == "copy-file": + if destination.exists() or destination.is_symlink(): + raise ExperimentError( + f"Disposable destination unexpectedly existed before copy: {destination}" + ) + _require_command( + logger.run( + ["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], + timeout_seconds=30.0, + ), + "copy selected resource", + ) + normalization: dict[str, Any] | None = None + destination_existed_before = False + elif modification.kind == "normalize-assbin-textures": + if not destination.is_file() or destination.is_symlink(): + raise ExperimentError( + f"Disposable Assbin target is missing or unsafe: {destination}" + ) + if sha256_file(destination) != sha256_file(source): + raise ExperimentError("Disposable Assbin differs from live source before transform") + temporary_output = destination.with_name( + f".{destination.name}.{result.path.name}.normalized" + ) + if temporary_output.exists() or temporary_output.is_symlink(): + raise ExperimentError(f"Normalization temporary output exists: {temporary_output}") + normalize_result = logger.run( + [ + sys.executable, + "-m", + "ptdiag", + "scene", + "normalize-textures", + str(destination), + str(temporary_output), + *(str(index) for index in modification.texture_indices), + ], + cwd=project_root(), + timeout_seconds=240.0, + ) + _require_command(normalize_result, "normalize embedded Assbin textures") + try: + normalization = json.loads(normalize_result.stdout) + except json.JSONDecodeError as exc: + raise ExperimentError("Normalizer returned invalid JSON") from exc + if not isinstance(normalization, dict) or not normalization.get("ok"): + raise ExperimentError("Normalizer did not report a verified result") + _require_command( + logger.run( + ["mv", "--", str(temporary_output), str(destination)], + timeout_seconds=30.0, + ), + "install normalized Assbin in disposable tree", + ) + destination_existed_before = True + else: + raise ExperimentError(f"Unsupported modification kind: {modification.kind}") + destination_hash = sha256_file(destination) + source_hash = sha256_file(source) + if modification.kind == "copy-file" and destination_hash != source_hash: + raise ExperimentError("Copied resource hash differs from its source") + if modification.kind == "normalize-assbin-textures" and destination_hash == source_hash: + raise ExperimentError("Normalized Assbin hash did not change") + ini_changes = _apply_disposable_ini_overrides( + disposable_game, + modification.ini_overrides, + logger, + ) + return { + "staging_root": str(staging_root), + "free_bytes_before": usage.free, + "source_allocated_bytes": copy_bytes, + "destination_path": str(destination), + "destination_existed_before": destination_existed_before, + "destination_sha256_after": destination_hash, + "destination_size_after": destination.stat().st_size, + "normalization": normalization, + "ini_changes": ini_changes, + } + + +def _inspect_ini_overrides( + game_root: Path, + overrides: Sequence[TestIniOverride], +) -> list[dict[str, Any]]: + root = game_root.resolve(strict=True) + result: list[dict[str, Any]] = [] + for override in overrides: + path = _safe_game_file(root, override.path) + state = inspect_ini_value(path, override.section, override.key) + result.append( + { + "path": str(path), + "section": override.section, + "key": override.key, + "expected": override.expected, + "value": override.value, + "current": state["value"], + "matches_expected": state["value"] == override.expected, + "matches_value": state["value"] == override.value, + "sha256": state["sha256"], + "size": state["size"], + "mtime_ns": state["mtime_ns"], + } + ) + return result + + +def _apply_disposable_ini_overrides( + disposable_game: Path, + overrides: Sequence[TestIniOverride], + logger: CommandLogger, +) -> list[dict[str, Any]]: + root = disposable_game.resolve(strict=True) + result: list[dict[str, Any]] = [] + for override in overrides: + path = _safe_game_file(root, override.path) + outcome = logger.run( + _ini_override_command(path, override), + cwd=project_root(), + timeout_seconds=30.0, + ) + _require_command(outcome, f"override [{override.section}] {override.key}") + try: + payload = json.loads(outcome.stdout) + except json.JSONDecodeError as exc: + raise ExperimentError("INI override returned invalid JSON") from exc + if not isinstance(payload, dict) or not payload.get("ok"): + raise ExperimentError("INI override did not report a verified result") + result.append(payload) + return result + + +def _safe_game_file(root: Path, relative: str) -> Path: + parts = _safe_resource_relative_path(relative).parts + path = root.joinpath(*parts) + resolved = path.resolve(strict=True) + try: + resolved.relative_to(root) + except ValueError as exc: + raise ExperimentError(f"INI path escapes game tree: {relative}") from exc + if path.is_symlink() or not resolved.is_file(): + raise ExperimentError(f"INI path is missing or unsafe: {path}") + return resolved + + +def _ini_override_command(path: Path, override: TestIniOverride) -> list[str]: + return [ + sys.executable, + "-m", + "ptdiag", + "ini", + "override", + str(path), + override.section, + override.key, + override.expected, + override.value, + ] + + +def _replace_game_link( + prefix_link: Path, + disposable_game: Path, + expected_live_game: Path, + logger: CommandLogger, +) -> str: + if not prefix_link.is_symlink(): + raise ExperimentError(f"Dedicated-prefix game path is not a symlink: {prefix_link}") + if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True): + raise ExperimentError("Dedicated-prefix game link changed after source preflight") + original_target = os.readlink(prefix_link) + _require_command( + logger.run( + ["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], + cwd=prefix_link.parent, + timeout_seconds=15.0, + ), + "point dedicated prefix at disposable game", + ) + if not prefix_link.is_symlink() or prefix_link.resolve(strict=True) != disposable_game.resolve(strict=True): + raise ExperimentError("Dedicated-prefix game link did not resolve to disposable copy") + return original_target + + +def _restore_game_link( + prefix_link: Path, + original_target: str, + expected_live_game: Path, + logger: CommandLogger, +) -> None: + _require_command( + logger.run( + ["ln", "-sfn", "--", original_target, str(prefix_link)], + cwd=prefix_link.parent, + timeout_seconds=15.0, + ), + "restore dedicated-prefix game link", + ) + if not prefix_link.is_symlink(): + raise ExperimentError("Restored dedicated-prefix game path is not a symlink") + if os.readlink(prefix_link) != original_target: + raise ExperimentError("Dedicated-prefix game link text was not restored exactly") + if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True): + raise ExperimentError("Dedicated-prefix game link target was not restored") + + +def _remove_disposable_game( + config: AppConfig, + result: ResultDirectory, + disposable_game: Path, + logger: CommandLogger, +) -> None: + staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute() + _validate_disposable_path(staging_root, disposable_game, result.path.name) + _require_command( + logger.run( + ["rm", "-rf", "--one-file-system", "--", str(disposable_game)], + timeout_seconds=900.0, + ), + "remove disposable game tree", + ) + if disposable_game.exists() or disposable_game.is_symlink(): + raise ExperimentError(f"Disposable game cleanup left its target: {disposable_game}") + try: + staging_root.rmdir() + except OSError: + pass + + +def _validate_disposable_path(staging_root: Path, path: Path, expected_name: str) -> None: + if path.parent != staging_root or path.name != expected_name: + raise ExperimentError(f"Unsafe disposable game path: {path}") + if staging_root.name != _DISPOSABLE_DIRECTORY_NAME: + raise ExperimentError(f"Unsafe disposable staging root: {staging_root}") + + +def _allocated_tree_bytes(root: Path) -> int: + total = 0 + for directory, _, filenames in os.walk(root, followlinks=False): + for filename in filenames: + path = Path(directory) / filename + try: + total += path.lstat().st_blocks * 512 + except FileNotFoundError: + continue + return total + + +def _require_command(outcome: CommandResult, operation: str) -> None: + if not _command_ok(outcome): + raise ExperimentError( + f"Could not {operation} ({outcome.returncode}): " + f"{outcome.error or outcome.stderr.strip() or 'command failed'}" + ) + + +def _log_modification_plan( + logger: CommandLogger, + config: AppConfig, + disposable_game: Path, + source: Path, + modification: TestModification, +) -> None: + staging_root = disposable_game.parent + destination = disposable_game.joinpath( + *_safe_resource_relative_path(modification.destination).parts + ) + prefix_link = config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU" + commands: list[tuple[list[str], float]] = [ + (["mkdir", "-p", "--", str(staging_root)], 15.0), + ( + [ + "cp", + "-a", + "--reflink=auto", + "--sparse=always", + f"{config.paths.live_game}/.", + str(disposable_game), + ], + 900.0, + ), + ] + if modification.kind == "copy-file": + commands.append( + (["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], 30.0) + ) + elif modification.kind == "normalize-assbin-textures": + temporary_output = destination.with_name(f".{destination.name}.planned.normalized") + commands.extend( + ( + ( + [ + sys.executable, + "-m", + "ptdiag", + "scene", + "normalize-textures", + str(destination), + str(temporary_output), + *(str(index) for index in modification.texture_indices), + ], + 240.0, + ), + (["mv", "--", str(temporary_output), str(destination)], 30.0), + ) + ) + else: + raise ExperimentError(f"Unsupported modification kind: {modification.kind}") + for override in modification.ini_overrides: + ini_path = disposable_game.joinpath( + *_safe_resource_relative_path(override.path).parts + ) + commands.append((_ini_override_command(ini_path, override), 30.0)) + commands.extend( + ( + (["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], 15.0), + (["ln", "-sfn", "--", os.readlink(prefix_link), str(prefix_link)], 15.0), + (["rm", "-rf", "--one-file-system", "--", str(disposable_game)], 900.0), + ) + ) + for command, timeout in commands: + logger.run(command, timeout_seconds=timeout, dry_run=True) + + +def _setup_failure_summary( + testcase: Testcase, + result: ResultDirectory, + dry_run: bool, + errors: Sequence[str], + modification: Mapping[str, Any], +) -> dict[str, Any]: + return { + "ok": False, + "status": "preflight-failed" if errors else "setup-error", + "dry_run": dry_run, + "test": testcase.name, + "result_directory": str(result.path), + "preflight": {"ok": False, "errors": list(errors)}, + "modification": dict(modification), + "native_log": {"source": None, "current_run_bytes": 0}, + "registry": {"restored": None}, + } + + +def _preflight( + config: AppConfig, + testcase: Testcase, + environment: Mapping[str, Any], + hashes: list[dict[str, Any]], +) -> list[str]: + errors: list[str] = [] + if not environment.get("ok"): + errors.append("Required environment checks failed") + if any(not item.get("ok", False) for item in hashes if item.get("required", False)): + errors.append("A required baseline hash is missing or mismatched") + if testcase.runner != "proton7": + errors.append(f"Unsupported runner: {testcase.runner}") + if testcase.game.args != config.game.args: + errors.append("Testcase game arguments differ from the known PTEU_REBORN baseline") + if testcase.game.cwd != "live_game": + errors.append("Only the verified live_game working directory is supported") + if testcase.display.size != config.game.virtual_desktop: + errors.append("Testcase desktop size differs from the 1600x900 baseline") + if testcase.capture.strace_files and shutil.which("strace") is None: + errors.append("scene-strace requires strace") + if not testcase.watch.require_new_native_log: + errors.append("Runtime tests must require a current-run native log") + try: + prefix = validate_dedicated_prefix(config) + except (OSError, ValueError, PrefixSafetyError) as exc: + errors.append(str(exc)) + else: + linked_game = prefix / "drive_c/Program Files (x86)/Pristontale EU" + try: + if linked_game.resolve(strict=True) != config.paths.live_game.resolve(strict=True): + errors.append("Dedicated prefix game link does not resolve to live_game") + except OSError as exc: + errors.append(f"Dedicated prefix game link is invalid: {exc}") + active = _game_processes() + if active: + errors.append(f"Game.exe is already running: {active}") + return errors + + +def _dry_run_commands( + config: AppConfig, + testcase: Testcase, + result: ResultDirectory, + logger: CommandLogger, + registry_before: DesktopRegistrySnapshot, + launch_argv: list[str], + env_overrides: Mapping[str, str], +) -> None: + cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) + if testcase.display.virtual_desktop: + for command in _registry_setup_commands( + config, registry_before.desktop_name, testcase.display.size + ): + logger.run( + command, + cwd=config.paths.live_game, + env_overrides=_proton_environment(config), + timeout_seconds=15.0, + dry_run=True, + ) + logger.run( + launch_argv, + cwd=config.paths.live_game, + env_overrides=env_overrides, + timeout_seconds=testcase.timeout_seconds, + dry_run=True, + ) + cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) + if testcase.display.virtual_desktop: + for command in _registry_restore_commands(config, registry_before): + logger.run( + command, + cwd=config.paths.live_game, + env_overrides=_proton_environment(config), + timeout_seconds=15.0, + dry_run=True, + ) + + +def _run_and_watch( + config: AppConfig, + testcase: Testcase, + result: ResultDirectory, + logger: CommandLogger, + argv: Sequence[str], + env_overrides: Mapping[str, str], + baseline: Mapping[str, LogFileState], +) -> dict[str, Any]: + runner_log_path = result.path / "runner.log" + environment = os.environ.copy() + environment.update(env_overrides) + test_start_ns = time.time_ns() + started_at = datetime.now().astimezone() + write_json( + result.path / "test-start.json", + { + "test_start_ns": test_start_ns, + "test_start_epoch": test_start_ns / 1_000_000_000, + "recorded_at": started_at.isoformat(), + }, + ) + process: subprocess.Popen[str] | None = None + process_identity: dict[str, Any] | None = None + failure_state: LogFileState | None = None + failure_analysis: dict[str, Any] | None = None + failure_text = "" + failure_trigger_mtime_ns: int | None = None + current_state: LogFileState | None = None + current_analysis: dict[str, Any] | None = None + current_text = "" + current_log_captures: list[dict[str, Any]] = [] + performance_sampler: PerformanceSampler | None = None + performance_summary: dict[str, Any] | None = None + detected_at_ns: int | None = None + timed_out = False + stop_reason = "process-exited" + with runner_log_path.open("a", encoding="utf-8") as runner_log: + try: + process = subprocess.Popen( + list(argv), + cwd=config.paths.live_game, + env=environment, + stdout=runner_log, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + except OSError as exc: + logger.record_lifecycle( + argv, + state="launch-error", + cwd=config.paths.live_game, + env_overrides=env_overrides, + timeout_seconds=testcase.timeout_seconds, + started_at=started_at, + error=str(exc), + ) + raise ExperimentError(f"Could not launch experiment: {exc}") from exc + logger.record_lifecycle( + argv, + state="started", + cwd=config.paths.live_game, + env_overrides=env_overrides, + timeout_seconds=testcase.timeout_seconds, + started_at=started_at, + pid=process.pid, + ) + deadline_ns = test_start_ns + int(testcase.timeout_seconds * 1_000_000_000) + post_failure_deadline_ns: int | None = None + while process.poll() is None: + now_ns = time.time_ns() + if process_identity is None: + process_identity = _capture_game_process(testcase, result.path) + if process_identity is not None and testcase.capture.performance_stats: + try: + performance_sampler = PerformanceSampler( + int(process_identity["pid"]), + result.path / "performance.csv", + ) + except (OSError, ValueError, KeyError) as exc: + performance_summary = {"error": str(exc)} + if performance_sampler is not None: + performance_sampler.maybe_sample(now_ns) + if failure_state is None: + for state in discover_current_native_logs( + config.paths.native_logs, baseline, test_start_ns + ): + analysis = parse_current_run_log(state, baseline) + current_state = state + current_analysis = analysis.to_summary() + current_text = read_current_run_log_text(state, baseline) + if analysis.load_texture_to_material_failed: + failure_state = state + failure_analysis = analysis.to_summary() + failure_text = read_current_run_log_text(state, baseline) + failure_trigger_mtime_ns = state.mtime_ns + detected_at_ns = now_ns + post_failure_deadline_ns = now_ns + int( + testcase.stop_after_failure_seconds * 1_000_000_000 + ) + break + if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns: + stop_reason = "load-texture-to-material" + _stop_process_group(process) + break + if now_ns >= deadline_ns: + timed_out = True + stop_reason = "timeout" + _stop_process_group(process) + break + time.sleep(0.1) + if process.poll() is None: + _stop_process_group(process) + returncode = process.wait(timeout=5.0) + if performance_sampler is not None: + performance_summary = performance_sampler.close() + logger.record_lifecycle( + argv, + state="finished", + cwd=config.paths.live_game, + env_overrides=env_overrides, + timeout_seconds=testcase.timeout_seconds, + started_at=started_at, + pid=process.pid, + returncode=returncode, + timed_out=timed_out, + ) + + final_current = discover_current_native_logs( + config.paths.native_logs, baseline, test_start_ns + ) + if final_current: + candidates: list[ + tuple[tuple[int, int, int, int, str], LogFileState, str, dict[str, Any]] + ] = [] + for index, state in enumerate(final_current): + text = read_current_run_log_text(state, baseline) + analysis = parse_current_run_log(state, baseline).to_summary() + scenes = analysis.get("scenes") + scene_names = ( + sorted(str(name) for name in scenes) + if isinstance(scenes, Mapping) + else [] + ) + captured_path = ( + result.path / "native-logs" / f"{index:02d}-{state.path.name}.current.log" + ) + _write_text(captured_path, text) + current_log_captures.append( + { + "source": str(state.path), + "state": _serialize_log_state(state), + "captured_path": str(captured_path), + "current_run_bytes": len(text.encode("utf-8")), + "scene_names": scene_names, + "load_texture_to_material_failed": analysis.get( + "load_texture_to_material_failed", False + ), + "import_scene_threw": analysis.get("import_scene_threw", False), + } + ) + candidates.append( + (_native_log_priority(state, analysis), state, text, analysis) + ) + _, current_state, current_text, current_analysis = max( + candidates, + key=lambda item: item[0], + ) + if failure_state is not None: + final_states = snapshot_native_logs(config.paths.native_logs) + final_state = final_states.get(str(failure_state.path.absolute())) + if final_state is not None: + failure_state = final_state + failure_text = read_current_run_log_text(final_state, baseline) + failure_analysis = parse_current_run_log(final_state, baseline).to_summary() + current_state = final_state + current_text = failure_text + current_analysis = failure_analysis + if current_state is not None: + _write_text(result.path / "native.log", current_text) + whispering_vale = ( + current_analysis.get("whispering_vale") + if isinstance(current_analysis, Mapping) + else None + ) + scene_import_survived = bool( + whispering_vale + and not timed_out + and not current_analysis.get("load_texture_to_material_failed", False) + and not current_analysis.get("import_scene_threw", False) + and not current_analysis.get("failed_import_scenes", []) + ) if isinstance(current_analysis, Mapping) else False + trigger_ns = ( + max(test_start_ns, failure_trigger_mtime_ns) + if failure_trigger_mtime_ns is not None + else None + ) + return { + "test_start_ns": test_start_ns, + "test_start_epoch": test_start_ns / 1_000_000_000, + "finished_at": datetime.now().astimezone().isoformat(), + "launcher_pid": process.pid if process is not None else None, + "game_process": process_identity, + "returncode": process.returncode if process is not None else None, + "timed_out": timed_out, + "stop_reason": stop_reason, + "failure_detected": failure_state is not None, + "scene_import_survived": scene_import_survived, + "detected_at_ns": detected_at_ns, + "trigger_epoch": trigger_ns / 1_000_000_000 if trigger_ns is not None else None, + "analysis": current_analysis, + "native_logs": current_log_captures, + "performance": performance_summary, + "native_log": { + "source": str(current_state.path) if current_state else None, + "state": _serialize_log_state(current_state) if current_state else None, + "current_run_bytes": len(current_text.encode("utf-8")), + }, + } + + +def _native_log_priority( + state: LogFileState, + analysis: Mapping[str, Any], +) -> tuple[int, int, int, int, str]: + scenes = analysis.get("scenes") + scene_count = 0 + if isinstance(scenes, Mapping): + for imports in scenes.values(): + if isinstance(imports, list): + scene_count += len(imports) + return ( + int(scene_count > 0), + scene_count, + int(state.path.name.casefold().startswith("log_")), + state.mtime_ns, + str(state.path), + ) + + +def _stop_process_group(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + for signum, timeout in ( + (signal.SIGINT, 5.0), + (signal.SIGTERM, 5.0), + (signal.SIGKILL, 3.0), + ): + try: + os.killpg(process.pid, signum) + except ProcessLookupError: + return + try: + process.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + continue + + +def _capture_game_process(testcase: Testcase, result_directory: Path) -> dict[str, Any] | None: + processes = _game_processes() + if not processes: + return None + pid = processes[0] + root = Path("/proc") / str(pid) + identity: dict[str, Any] = {"pid": pid} + try: + identity["comm"] = (root / "comm").read_text(encoding="utf-8").strip() + identity["cmdline"] = ( + (root / "cmdline").read_bytes().decode("utf-8", errors="replace").split("\0") + ) + identity["cwd"] = str((root / "cwd").readlink()) + identity["exe"] = str((root / "exe").readlink()) + if testcase.capture.process_maps: + shutil.copyfile(root / "maps", result_directory / "process-maps.txt") + except OSError as exc: + identity["capture_error"] = str(exc) + return identity + + +def _game_processes() -> list[int]: + result: list[int] = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + comm = (entry / "comm").read_text(encoding="utf-8").strip().casefold() + except OSError: + continue + if comm in {"game.exe", "gamese.exe"}: + result.append(int(entry.name)) + return sorted(result) + + +def _launch_argv(config: AppConfig, testcase: Testcase, result_directory: Path) -> list[str]: + game = f"{_WINDOWS_GAME_DIRECTORY}\\{config.game.exe}" + proton = _proton_command(config, "runinprefix", game, *testcase.game.args) + if not testcase.capture.strace_files: + return proton + raw_directory = result_directory / "raw-strace" + raw_directory.mkdir(exist_ok=False) + strace = shutil.which("strace") or "/usr/bin/strace" + return [ + strace, + "-ff", + "-qq", + "-ttt", + "-s", + "1024", + "-e", + "trace=%file", + "-o", + str(raw_directory / "trace"), + *proton, + ] + + +def _proton_command(config: AppConfig, *arguments: str) -> list[str]: + shim = Path(__file__).with_name("proton_compat.py") + return [sys.executable, str(shim), str(config.proton7.proton), *arguments] + + +def _runtime_environment( + config: AppConfig, + testcase: Testcase, + result_directory: Path, +) -> dict[str, str]: + environment = _proton_environment(config) + environment.update(testcase.env) + if environment.get("DXVK_STATE_CACHE_PATH") == _PERSISTENT_STATE_CACHE_SENTINEL: + environment["DXVK_STATE_CACHE_PATH"] = str( + (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() + ) + environment.update( + { + "DXVK_LOG_PATH": str(result_directory), + "PROTON_LOG": "0", + "STEAM_COMPAT_INSTALL_PATH": str(config.paths.live_game), + "SteamAppId": "0", + "SteamGameId": "0", + } + ) + return environment + + +def _state_cache_plan( + config: AppConfig, + environment: Mapping[str, str], +) -> dict[str, Any] | None: + configured = environment.get("DXVK_STATE_CACHE_PATH") + if configured is None: + return None + cache_directory = Path(configured).expanduser().absolute() + cache_file = cache_directory / f"{Path(config.game.exe).stem}.dxvk-cache" + return { + "directory": str(cache_directory), + "cache_file": str(cache_file), + "persistent": True, + "seed_source": str(config.paths.live_game / cache_file.name), + "seeded": False, + "before": _state_cache_file_snapshot(cache_file), + "after": None, + } + + +def _prepare_state_cache( + config: AppConfig, + environment: Mapping[str, str], + logger: CommandLogger, +) -> dict[str, Any]: + plan = _state_cache_plan(config, environment) + if plan is None: + raise ExperimentError("Persistent DXVK state-cache plan is missing") + cache_directory = Path(str(plan["directory"])) + expected = (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() + if cache_directory != expected: + raise ExperimentError( + f"Refusing unmanaged DXVK state-cache directory: {cache_directory}; " + f"expected {expected}" + ) + _require_command( + logger.run( + ["mkdir", "-p", "--", str(cache_directory)], + timeout_seconds=15.0, + ), + "create managed DXVK state-cache directory", + ) + cache_file = Path(str(plan["cache_file"])) + seed_source = Path(str(plan["seed_source"])) + if not cache_file.exists() and seed_source.is_file(): + _require_command( + logger.run( + [ + "cp", + "--preserve=mode,timestamps", + "--", + str(seed_source), + str(cache_file), + ], + timeout_seconds=30.0, + ), + "seed managed DXVK state cache", + ) + plan["seeded"] = True + plan["before"] = _state_cache_file_snapshot(cache_file) + return plan + + +def _state_cache_file_snapshot(path: Path) -> dict[str, Any]: + result: dict[str, Any] = { + "path": str(path), + "exists": path.is_file(), + "size": None, + "mtime_ns": None, + "sha256": None, + } + if not result["exists"]: + return result + try: + state = path.stat() + result.update( + { + "size": state.st_size, + "mtime_ns": state.st_mtime_ns, + "sha256": sha256_file(path), + } + ) + except OSError as exc: + result["error"] = str(exc) + return result + + +def _proton_environment(config: AppConfig) -> dict[str, str]: + return { + "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(config.proton7.steam_root), + "STEAM_COMPAT_DATA_PATH": str(config.proton7.compat), + "WINEPREFIX": str(config.proton7.prefix), + "WINEDEBUG": "-all", + } + + +def _registry_setup_commands( + config: AppConfig, + desktop_name: str, + desktop_size: str, +) -> list[list[str]]: + return [ + _proton_command( + config, + "runinprefix", + "reg.exe", + "add", + _EXPLORER_KEY, + "/v", + "Desktop", + "/t", + "REG_SZ", + "/d", + desktop_name, + "/f", + ), + _proton_command( + config, + "runinprefix", + "reg.exe", + "add", + _DESKTOPS_KEY, + "/v", + desktop_name, + "/t", + "REG_SZ", + "/d", + desktop_size, + "/f", + ), + ] + + +def _registry_restore_commands( + config: AppConfig, + snapshot: DesktopRegistrySnapshot, +) -> list[list[str]]: + return [ + _registry_restore_command( + config, + _EXPLORER_KEY, + "Desktop", + snapshot.selected_desktop.exists, + snapshot.selected_desktop.value, + ), + _registry_restore_command( + config, + _DESKTOPS_KEY, + snapshot.desktop_name, + snapshot.desktop_size.exists, + snapshot.desktop_size.value, + ), + ] + + +def _registry_restore_command( + config: AppConfig, + key: str, + name: str, + existed: bool, + value: str | None, +) -> list[str]: + if existed: + assert value is not None + return _proton_command( + config, + "runinprefix", + "reg.exe", + "add", + key, + "/v", + name, + "/t", + "REG_SZ", + "/d", + value, + "/f", + ) + return _proton_command( + config, + "runinprefix", + "reg.exe", + "delete", + key, + "/v", + name, + "/f", + ) + + +def _run_checked_commands( + logger: CommandLogger, + commands: Sequence[Sequence[str]], + *, + cwd: Path, + env_overrides: Mapping[str, str], +) -> None: + for command in commands: + outcome = logger.run( + command, + cwd=cwd, + env_overrides=env_overrides, + timeout_seconds=15.0, + ) + if not _command_ok(outcome): + raise ExperimentError( + f"Command failed ({outcome.returncode}): {list(command)!r}: " + f"{outcome.error or outcome.stderr.strip()}" + ) + + +def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None: + outcome = cleanup_dedicated_wineserver( + config, + logger, + timeout_seconds=15.0, + ) + if ( + outcome.returncode == 1 + and not outcome.timed_out + and outcome.error is None + and not outcome.stdout + and not outcome.stderr + ): + # Proton 7 wineserver -k returns 1 when this dedicated prefix has no + # server to stop. That is the desired idempotent cleanup state. + return + if not _command_ok(outcome): + raise ExperimentError( + "Dedicated-prefix wineserver cleanup failed: " + f"{outcome.error or outcome.stderr.strip() or outcome.returncode}" + ) + + +def _command_ok(result: CommandResult) -> bool: + return not result.timed_out and result.error is None and result.returncode == 0 + + +def _collect_hashes(config: AppConfig, testcase: Testcase) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for verification in verify_known_game_hashes(config.paths.live_game): + item = verification.to_dict() + item["required"] = True + item["role"] = "known-game-baseline" + items.append(item) + extra = { + config.proton7.proton: "proton-launcher", + config.proton7.prefix / "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43", + config.proton7.prefix + / "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43", + config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9", + Path(__file__).with_name("proton_compat.py"): "proton-python314-shim", + config.source: "local-config", + testcase.source: "testcase", + } + dxvk_config = testcase.env.get("DXVK_CONFIG_FILE") + if dxvk_config: + dxvk_config_path = Path(dxvk_config).expanduser() + if not dxvk_config_path.is_absolute(): + dxvk_config_path = (project_root() / dxvk_config_path).absolute() + extra[dxvk_config_path] = "dxvk-config" + for path, role in extra.items(): + item: dict[str, Any] = { + "path": str(path), + "role": role, + "required": role not in {"local-config", "testcase"}, + "expected_sha256": None, + "actual_sha256": None, + "ok": False, + "error": None, + } + try: + item["actual_sha256"] = sha256_file(path) + item["ok"] = True + except OSError as exc: + item["error"] = str(exc) + items.append(item) + return items + + +def _collect_dll_overrides(prefix: Path) -> dict[str, str]: + sections = read_registry_string_values(prefix / "user.reg") + return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items())) + + +def _serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]: + return [_serialize_log_state(state) for state in sorted(states.values(), key=lambda x: str(x.path))] + + +def _serialize_log_state(state: LogFileState) -> dict[str, Any]: + return { + "path": str(state.path), + "mtime_ns": state.mtime_ns, + "size": state.size, + "inode": state.inode, + } + + +def _desktop_name(result_directory: Path) -> str: + digits = "".join(character for character in result_directory.name if character.isdigit()) + return f"PTDiag{digits[-12:]}" + + +def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None: + write_json(result.path / "summary.json", summary) + _write_text(result.path / "report.md", _report(summary)) + + +def _report(summary: Mapping[str, Any]) -> str: + preflight = summary.get("preflight", {}) + launch = summary.get("launch", {}) + registry = summary.get("registry", {}) + native_log = summary.get("native_log", {}) + modification = summary.get("modification") + state_cache = summary.get("dxvk_state_cache") + runtime = summary.get("runtime") + lines = [ + f"# {summary.get('test', 'experiment')}", + "", + f"Status: `{summary.get('status')}`", + f"Result directory: `{summary.get('result_directory')}`", + f"Dry run: `{summary.get('dry_run')}`", + "", + "## Preflight", + "", + f"Passed: `{preflight.get('ok')}`" if isinstance(preflight, Mapping) else "Passed: `False`", + ] + if isinstance(preflight, Mapping): + for error in preflight.get("errors", []): + lines.append(f"- {error}") + lines.extend( + [ + "", + "## Launch", + "", + f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "", + f"Timeout: `{launch.get('timeout_seconds')}` seconds" + if isinstance(launch, Mapping) + else "", + "", + "## Current-run native log", + "", + f"Source: `{native_log.get('source')}`" if isinstance(native_log, Mapping) else "", + f"Captured bytes: `{native_log.get('current_run_bytes')}`" + if isinstance(native_log, Mapping) + else "", + "", + "## Registry rollback", + "", + f"Restored: `{registry.get('restored')}`" if isinstance(registry, Mapping) else "", + ] + ) + if summary.get("review_gate"): + lines.extend(["", "## Review gate", "", str(summary["review_gate"])]) + if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping): + performance = runtime["performance"] + lines.extend( + [ + "", + "## Performance capture", + "", + f"Samples: `{performance.get('sample_count')}`", + f"Interval: `{performance.get('interval_seconds')}` seconds", + f"CSV: `{performance.get('output')}`", + ] + ) + if isinstance(state_cache, Mapping): + lines.extend( + [ + "", + "## DXVK state cache", + "", + f"Directory: `{state_cache.get('directory')}`", + f"Seeded: `{state_cache.get('seeded')}`", + f"Before: `{state_cache.get('before')}`", + f"After: `{state_cache.get('after')}`", + ] + ) + if isinstance(modification, Mapping): + lines.extend( + [ + "", + "## Disposable modification", + "", + f"Kind: `{modification.get('kind')}`", + f"Source: `{modification.get('source')}`", + f"Destination: `{modification.get('destination')}`", + f"Prepared: `{modification.get('prepared')}`", + f"Prefix link restored: `{modification.get('prefix_link_restored')}`", + f"Disposable copy removed: `{modification.get('disposable_removed')}`", + ] + ) + if summary.get("error"): + lines.extend(["", "## Error", "", str(summary["error"])]) + return "\n".join(line for line in lines if line is not None).rstrip() + "\n" + + +def _write_text(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8") + temporary.replace(path) diff --git a/ptdiag/filesystem.py b/ptdiag/filesystem.py new file mode 100644 index 0000000..f41fbba --- /dev/null +++ b/ptdiag/filesystem.py @@ -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) + diff --git a/ptdiag/ini.py b/ptdiag/ini.py new file mode 100644 index 0000000..9e175d8 --- /dev/null +++ b/ptdiag/ini.py @@ -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] diff --git a/ptdiag/logs.py b/ptdiag/logs.py new file mode 100644 index 0000000..5e397c5 --- /dev/null +++ b/ptdiag/logs.py @@ -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[^:]+):\s*" + r"materials=(?P\d+)\s+textures=(?P\d+)", + re.IGNORECASE, +) +_FAILED_FILE_RE = re.compile( + r"Failed to (?Pfind|load|read) file\s*:\s*(?P.*)$", + re.IGNORECASE, +) +_FAILED_IMPORT_RE = re.compile( + r"Failed to import scene:\s*(?P.*)$", + re.IGNORECASE, +) +_TIMESTAMP_RE = re.compile( + r"^\s*(?:\[)?(?P" + 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 diff --git a/ptdiag/performance.py b/ptdiag/performance.py new file mode 100644 index 0000000..407fe1c --- /dev/null +++ b/ptdiag/performance.py @@ -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 diff --git a/ptdiag/prefix.py b/ptdiag/prefix.py new file mode 100644 index 0000000..b1a4074 --- /dev/null +++ b/ptdiag/prefix.py @@ -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, + ) diff --git a/ptdiag/process.py b/ptdiag/process.py new file mode 100644 index 0000000..5b9f10c --- /dev/null +++ b/ptdiag/process.py @@ -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", + ) diff --git a/ptdiag/proton_compat.py b/ptdiag/proton_compat.py new file mode 100644 index 0000000..5e3af2e --- /dev/null +++ b/ptdiag/proton_compat.py @@ -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() diff --git a/ptdiag/registry.py b/ptdiag/registry.py new file mode 100644 index 0000000..9781d60 --- /dev/null +++ b/ptdiag/registry.py @@ -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"(?:\\.|[^"\\])*")=(?P.*)$') +_SECTION_RE = re.compile(r"^\[(?P.*)\](?:\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 diff --git a/ptdiag/result.py b/ptdiag/result.py new file mode 100644 index 0000000..dae26ad --- /dev/null +++ b/ptdiag/result.py @@ -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}") + diff --git a/ptdiag/runner.py b/ptdiag/runner.py new file mode 100644 index 0000000..155cb2a --- /dev/null +++ b/ptdiag/runner.py @@ -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 diff --git a/ptdiag/testcase.py b/ptdiag/testcase.py new file mode 100644 index 0000000..0027184 --- /dev/null +++ b/ptdiag/testcase.py @@ -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) diff --git a/ptdiag/trace.py b/ptdiag/trace.py new file mode 100644 index 0000000..fed01cf --- /dev/null +++ b/ptdiag/trace.py @@ -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[0-9]+(?:\.[0-9]+)?)\s+(?P.*)$") +_SYSCALL_RE = re.compile(r"^(?P[A-Za-z_][A-Za-z0-9_]*)\(") +_ERRNO_RE = re.compile( + r"=\s+-1\s+(?PE[A-Z0-9]+)\s+\((?P[^)]*)\)" +) +_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 "." diff --git a/ptlinux/__init__.py b/ptlinux/__init__.py new file mode 100644 index 0000000..674ea39 --- /dev/null +++ b/ptlinux/__init__.py @@ -0,0 +1,7 @@ +"""Portable PristonTale EU installer and Linux launcher.""" + +from .paths import AppPaths + +__all__ = ["AppPaths", "__version__"] + +__version__ = "0.5.1" diff --git a/ptlinux/__main__.py b/ptlinux/__main__.py new file mode 100644 index 0000000..faaa63b --- /dev/null +++ b/ptlinux/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + + +raise SystemExit(main()) diff --git a/ptlinux/cli.py b/ptlinux/cli.py new file mode 100644 index 0000000..1a3ef16 --- /dev/null +++ b/ptlinux/cli.py @@ -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)) diff --git a/ptlinux/command.py b/ptlinux/command.py new file mode 100644 index 0000000..747df96 --- /dev/null +++ b/ptlinux/command.py @@ -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 diff --git a/ptlinux/depot.py b/ptlinux/depot.py new file mode 100644 index 0000000..f2bfc84 --- /dev/null +++ b/ptlinux/depot.py @@ -0,0 +1,1091 @@ +"""Native, verified updater for the public PristonTale EU depot.""" + +from __future__ import annotations + +from base64 import b64decode +from binascii import Error as BinAsciiError +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from dataclasses import asdict, dataclass +from datetime import datetime +import hashlib +from http.client import HTTPException +import json +import os +from pathlib import Path, PurePosixPath +import re +import threading +import time +from typing import Callable, Iterable, Mapping, Protocol +from urllib.error import HTTPError +from urllib.parse import quote, urlparse +from urllib.request import Request, urlopen +from uuid import uuid4 + +from . import __version__ +from .paths import AppPaths + + +CDN_CONFIGURATION_URL = "https://cdn-main.chatplex.io/pristontale-eu/cdn_configuration.json" +DEPOT_ID = 1 +FALLBACK_SERVERS = ( + "cdn-fr-sbg1.chatplex.io/pristontale-eu", + "cdn-us-ore1.chatplex.io/pristontale-eu", +) +READ_SIZE = 128 * 1024 +HASH_READ_SIZE = 1024 * 1024 +DOWNLOAD_WORKERS = 8 +DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0) +PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 +MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 +MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 +SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") +CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) + + +class DepotError(RuntimeError): + """Raised when a depot response or update operation cannot be trusted.""" + + +class _RetryableDownloadError(DepotError): + """One transient network or content-integrity failure.""" + + def __init__(self, message: str, *, discard_partial: bool = False) -> None: + super().__init__(message) + self.discard_partial = discard_partial + + +@dataclass(frozen=True) +class DepotFile: + """One exact file in the published depot build.""" + + path: str + build: int + size: int + sha1: str + + +@dataclass(frozen=True) +class CreateIfMissing: + """A small publisher-provided file created only when absent.""" + + path: str + content_base64: str + + +@dataclass(frozen=True) +class DepotSnapshot: + """The current, verified shape of the public game depot.""" + + region: str + server: str + name: str + build: int + version: str + files: tuple[DepotFile, ...] + deleted_files: tuple[str, ...] + create_if_missing: tuple[CreateIfMissing, ...] + + +@dataclass(frozen=True) +class PlannedDownload: + file: DepotFile + reason: str + + +@dataclass(frozen=True) +class UpdatePlan: + """The safe changes needed to make a local tree match one depot snapshot.""" + + downloads: tuple[PlannedDownload, ...] + deletes: tuple[str, ...] + files_total: int + bytes_total: int + + +@dataclass(frozen=True) +class SyncProgress: + """A thread-safe progress snapshot emitted by ``sync_managed_game``.""" + + phase: str + files_completed: int + files_total: int + bytes_completed: int + bytes_total: int + current_file: str | None + bytes_transferred: int = 0 + + +@dataclass(frozen=True) +class DepotSyncResult: + """Persistent result of one native depot synchronization.""" + + region: str + server: str + name: str + build: int + version: str + files_total: int + files_downloaded: int + bytes_downloaded: int + files_deleted: int + files_created: int + repair: bool + dry_run: bool + completed_at: str + + +ProgressCallback = Callable[[SyncProgress], None] + + +class HttpResponse(Protocol): + """The small response surface used by the native depot client.""" + + headers: Mapping[str, str] + + def read(self, size: int = -1) -> bytes: ... + + def close(self) -> None: ... + + +HttpOpener = Callable[..., HttpResponse] + + +class DepotClient: + """Small HTTP client for the publisher's documented public depot endpoints.""" + + def __init__( + self, + *, + timeout_seconds: float = 60.0, + opener: HttpOpener = urlopen, + retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS, + sleeper: Callable[[float], None] = time.sleep, + ) -> None: + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + if any(delay < 0 for delay in retry_delays): + raise ValueError("retry delays must not be negative") + self.timeout_seconds = timeout_seconds + self._opener = opener + self._retry_delays = retry_delays + self._sleeper = sleeper + self._candidate_servers: tuple[str, ...] = () + + def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot: + """Fetch the live depot and build manifests, trying trusted regional fallbacks.""" + + configuration: Mapping[str, object] | None = None + configuration_error: DepotError | None = None + try: + configuration = self._get_json(CDN_CONFIGURATION_URL) + except DepotError as exc: + configuration_error = exc + + candidates = _server_candidates(configuration, preferred_region) + self._candidate_servers = tuple(server for _region, server in candidates) + last_error = configuration_error + for region, server in candidates: + try: + depot_manifest = self._get_json(_depot_manifest_url(server)) + build = _positive_int(depot_manifest.get("Build"), "depot Build") + build_manifest = self._get_json(_build_manifest_url(server, build)) + return _parse_snapshot(region, server, depot_manifest, build_manifest) + except DepotError as exc: + last_error = exc + + if last_error is not None: + raise DepotError(f"could not load the PristonTale EU depot: {last_error}") + raise DepotError("could not load the PristonTale EU depot") + + def download_file( + self, + server: str, + file: DepotFile, + destination: Path, + *, + progress: Callable[[int], None] | None = None, + ) -> None: + """Download, resume, and verify one file before replacing its destination. + + The current publisher CDN normalizes a small number of legacy text files + from CRLF to LF while its manifest still hashes the CRLF bytes. A + candidate is accepted only when restoring bare LF line endings produces + the manifest's exact size and SHA-1. + """ + + servers = _ordered_download_servers(server, self._candidate_servers) + temporary = _download_partial_path(destination) + last_error: _RetryableDownloadError | None = None + attempts = len(self._retry_delays) + 1 + for attempt_index in range(attempts): + attempt_server = servers[attempt_index % len(servers)] + try: + self._download_file_once( + attempt_server, + file, + destination, + progress=progress, + ) + return + except _RetryableDownloadError as exc: + last_error = exc + if exc.discard_partial: + temporary.unlink(missing_ok=True) + if attempt_index >= len(self._retry_delays): + break + self._sleeper(self._retry_delays[attempt_index]) + + assert last_error is not None + resume_hint = ( + "; partial data was retained for the next update run" + if temporary.is_file() and not temporary.is_symlink() + else "" + ) + raise DepotError( + f"download failed for {file.path} after {attempts} attempts: " + f"{last_error}{resume_hint}" + ) from last_error + + def _download_file_once( + self, + server: str, + file: DepotFile, + destination: Path, + *, + progress: Callable[[int], None] | None, + ) -> None: + temporary = _download_partial_path(destination) + written = 0 + digest = hashlib.sha1(usedforsecurity=False) + if temporary.is_symlink(): + raise DepotError(f"refusing to use symbolic-link partial file for {file.path}") + if temporary.exists(): + if not temporary.is_file(): + raise DepotError(f"partial download is not a file for {file.path}") + try: + with temporary.open("rb") as partial_handle: + while True: + chunk = partial_handle.read(HASH_READ_SIZE) + if not chunk: + break + written += len(chunk) + digest.update(chunk) + except OSError as exc: + raise DepotError(f"cannot inspect partial download for {file.path}: {exc}") from exc + + if written > file.size: + temporary.unlink() + written = 0 + digest = hashlib.sha1(usedforsecurity=False) + elif written == file.size: + if digest.hexdigest().casefold() == file.sha1.casefold(): + temporary.replace(destination) + if progress is not None: + progress(written) + return + temporary.unlink() + written = 0 + digest = hashlib.sha1(usedforsecurity=False) + elif written and _recover_crlf_manifest_file(temporary, destination, file): + temporary.unlink(missing_ok=True) + if progress is not None: + progress(file.size) + return + if progress is not None: + progress(written) + + requested_offset = written + headers = _headers() + if requested_offset: + headers["Range"] = f"bytes={requested_offset}-" + url = _file_url(server, file) + request = Request(url, headers=headers, method="GET") + try: + response = self._opener(request, timeout=self.timeout_seconds) + except HTTPError as exc: + if exc.code == 416 and requested_offset: + raise _RetryableDownloadError( + f"server rejected the saved offset for {file.path}", + discard_partial=True, + ) from exc + raise _RetryableDownloadError( + f"HTTP {exc.code} from {server} for {file.path}" + ) from exc + except (OSError, HTTPException) as exc: + raise _RetryableDownloadError( + f"could not connect to {server} for {file.path}: {exc}" + ) from exc + + try: + append = False + if requested_offset: + content_range = response.headers.get("Content-Range") + status = getattr(response, "status", None) + if status == 206 or content_range is not None: + try: + range_start, _range_end, range_total = _content_range(content_range) + except DepotError as exc: + raise _RetryableDownloadError( + f"invalid resume response from {server} for {file.path}: {exc}" + ) from exc + if range_start != requested_offset or range_total != file.size: + raise _RetryableDownloadError( + f"resume response did not match {file.path}: " + f"offset {range_start}/{requested_offset}, size {range_total}/{file.size}" + ) + append = True + else: + # A server may ignore Range and send a normal 200 response. In + # that case safely restart this file using the full response body. + written = 0 + digest = hashlib.sha1(usedforsecurity=False) + if progress is not None: + progress(written) + + try: + content_length = _content_length(response.headers.get("Content-Length")) + except DepotError as exc: + raise _RetryableDownloadError( + f"invalid response length from {server} for {file.path}: {exc}" + ) from exc + remaining = file.size - written + if content_length is not None and content_length > remaining: + raise _RetryableDownloadError( + f"download exceeds expected size for {file.path}: " + f"server declared {content_length}, expected at most {remaining}" + ) + + mode = "ab" if append else "wb" + try: + with temporary.open(mode) as handle: + while True: + try: + chunk = response.read(READ_SIZE) + except (OSError, HTTPException) as exc: + raise _RetryableDownloadError( + f"could not read {file.path} from {server}: {exc}" + ) from exc + if not chunk: + break + written += len(chunk) + if written > file.size: + raise _RetryableDownloadError( + f"download exceeds expected size for {file.path}", + discard_partial=True, + ) + digest.update(chunk) + handle.write(chunk) + if progress is not None: + progress(written) + except _RetryableDownloadError: + raise + except OSError as exc: + raise DepotError(f"cannot write partial download for {file.path}: {exc}") from exc + + if written != file.size or digest.hexdigest().casefold() != file.sha1.casefold(): + if _recover_crlf_manifest_file(temporary, destination, file): + temporary.unlink(missing_ok=True) + if progress is not None: + progress(file.size) + return + if written != file.size: + raise _RetryableDownloadError( + f"incomplete response from {server} for {file.path}: " + f"{written}/{file.size} bytes" + ) + raise _RetryableDownloadError( + f"SHA-1 mismatch for {file.path} from {server}", + discard_partial=True, + ) + temporary.replace(destination) + finally: + try: + response.close() + except (OSError, HTTPException): + pass + + def _get_json(self, url: str) -> Mapping[str, object]: + request = Request(url, headers=_headers(), method="GET") + try: + response = self._opener(request, timeout=self.timeout_seconds) + except (OSError, HTTPException) as exc: + raise DepotError(f"request failed for {url}: {exc}") from exc + payload = b"" + try: + payload = response.read() + except (OSError, HTTPException) as exc: + raise DepotError(f"could not read {url}: {exc}") from exc + finally: + try: + response.close() + except (OSError, HTTPException): + pass + try: + value = json.loads(payload.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DepotError(f"invalid JSON from {url}") from exc + if not isinstance(value, dict): + raise DepotError(f"unexpected JSON root from {url}") + return value + + +def plan_update( + game_directory: Path, + snapshot: DepotSnapshot, + *, + repair: bool = False, +) -> UpdatePlan: + """Inspect a local game tree without writing to it.""" + + root = _prepare_game_root(game_directory, create=False) + downloads: list[PlannedDownload] = [] + for file in snapshot.files: + target = _safe_target(root, file.path) + if target.is_symlink(): + downloads.append(PlannedDownload(file, "symbolic link")) + continue + if not target.is_file(): + downloads.append(PlannedDownload(file, "missing")) + continue + try: + if target.stat().st_size != file.size: + downloads.append(PlannedDownload(file, "size mismatch")) + continue + except OSError as exc: + raise DepotError(f"cannot inspect {file.path}: {exc}") from exc + if repair or file.build == snapshot.build: + if _sha1(target).casefold() != file.sha1.casefold(): + downloads.append(PlannedDownload(file, "hash mismatch")) + + deletes: list[str] = [] + for relative_path in snapshot.deleted_files: + target = _safe_target(root, relative_path) + if target.is_symlink() or target.is_file(): + deletes.append(relative_path) + elif target.exists(): + raise DepotError( + f"refusing to delete non-file depot target: {relative_path}" + ) + + return UpdatePlan( + downloads=tuple(downloads), + deletes=tuple(deletes), + files_total=len(snapshot.files), + bytes_total=sum(item.file.size for item in downloads), + ) + + +def sync_managed_game( + paths: AppPaths, + *, + repair: bool = False, + dry_run: bool = False, + progress: ProgressCallback | None = None, + client: DepotClient | None = None, +) -> DepotSyncResult: + """Synchronize the managed game directory using only official public data.""" + + paths.create_base_directories() + depot_client = client or DepotClient() + _emit_progress(progress, "manifest", 0, 0, 0, 0, None) + snapshot = depot_client.fetch_snapshot() + plan = plan_update(paths.source_game, snapshot, repair=repair) + _emit_progress( + progress, + "planned", + 0, + len(plan.downloads), + 0, + plan.bytes_total, + None, + ) + + if dry_run: + return _sync_result(snapshot, plan, 0, 0, 0, repair, True) + + root = _prepare_game_root(paths.source_game, create=True) + _download_plan(depot_client, snapshot, root, plan, progress) + deleted = _apply_deletions(root, plan.deletes) + created = _apply_create_if_missing(root, snapshot.create_if_missing) + result = _sync_result( + snapshot, + plan, + len(plan.downloads), + plan.bytes_total, + deleted, + repair, + False, + created=created, + ) + _write_json(paths.native_depot_state, asdict(result)) + _emit_progress( + progress, + "complete", + len(plan.downloads), + len(plan.downloads), + plan.bytes_total, + plan.bytes_total, + None, + ) + return result + + +def _download_plan( + client: DepotClient, + depot_snapshot: DepotSnapshot, + root: Path, + plan: UpdatePlan, + progress: ProgressCallback | None, +) -> None: + if not plan.downloads: + return + + lock = threading.Lock() + emit_lock = threading.Lock() + current_bytes: dict[str, int] = {} + completed_files = 0 + completed_bytes = 0 + transferred_bytes = 0 + last_progress_emitted_at = 0.0 + + def emit_download_progress( + current_file: str, + *, + force: bool = False, + ) -> None: + """Throttle progress I/O without holding the download-state lock. + + The workers can produce many chunk and file-completion updates per second. + The callback writes to a subprocess pipe for the native dashboard, so + retaining every event would make that pipe and its UI consumer the bottleneck. + """ + + nonlocal last_progress_emitted_at + if progress is None: + return + with emit_lock: + now = time.monotonic() + if not force and now - last_progress_emitted_at < PROGRESS_UPDATE_INTERVAL_SECONDS: + return + last_progress_emitted_at = now + with lock: + event = SyncProgress( + "downloading", + completed_files, + len(plan.downloads), + completed_bytes + sum(current_bytes.values()), + plan.bytes_total, + current_file, + transferred_bytes, + ) + progress(event) + + def download(planned_download: PlannedDownload) -> None: + target = _safe_target(root, planned_download.file.path) + _ensure_target_parent(root, target) + + def report_file_bytes(written: int) -> None: + nonlocal transferred_bytes + with lock: + previous = current_bytes.get(planned_download.file.path) + if previous is not None and written > previous: + transferred_bytes += written - previous + current_bytes[planned_download.file.path] = written + emit_download_progress(planned_download.file.path) + + client.download_file( + depot_snapshot.server, + planned_download.file, + target, + progress=report_file_bytes if progress is not None else None, + ) + + worker_count = min(DOWNLOAD_WORKERS, len(plan.downloads)) + remaining_items = iter(plan.downloads) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + future_items: dict[Future[None], PlannedDownload] = {} + + def submit_next() -> bool: + try: + next_item = next(remaining_items) + except StopIteration: + return False + future_items[executor.submit(download, next_item)] = next_item + return True + + try: + for _worker in range(worker_count): + if not submit_next(): + break + while future_items: + done, _pending = wait( + tuple(future_items), + return_when=FIRST_COMPLETED, + ) + completed_items: list[PlannedDownload] = [] + for future in done: + completed_item = future_items.pop(future) + future.result() + completed_items.append(completed_item) + for completed_item in completed_items: + with lock: + completed_files += 1 + completed_bytes += completed_item.file.size + current_bytes.pop(completed_item.file.path, None) + is_final_download = completed_files == len(plan.downloads) + emit_download_progress( + completed_item.file.path, + force=is_final_download, + ) + for _item in completed_items: + submit_next() + except BaseException: + for future in future_items: + future.cancel() + raise + + +def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int: + deleted = 0 + for relative_path in relative_paths: + target = _safe_target(root, relative_path) + if target.is_symlink() or target.is_file(): + target.unlink() + deleted += 1 + return deleted + + +def _apply_create_if_missing( + root: Path, + entries: Iterable[CreateIfMissing], +) -> int: + created = 0 + for entry in entries: + target = _safe_target(root, entry.path) + if target.is_symlink(): + raise DepotError(f"refusing to follow symbolic link for {entry.path}") + if target.exists(): + continue + content = _decode_create_if_missing(entry) + _ensure_target_parent(root, target) + temporary = target.with_name(f".{target.name}.{uuid4().hex}.partial") + try: + with temporary.open("xb") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(target) + except BaseException: + temporary.unlink(missing_ok=True) + raise + created += 1 + return created + + +def _sync_result( + snapshot: DepotSnapshot, + plan: UpdatePlan, + files_downloaded: int, + bytes_downloaded: int, + files_deleted: int, + repair: bool, + dry_run: bool, + *, + created: int = 0, +) -> DepotSyncResult: + return DepotSyncResult( + region=snapshot.region, + server=snapshot.server, + name=snapshot.name, + build=snapshot.build, + version=snapshot.version, + files_total=plan.files_total, + files_downloaded=files_downloaded, + bytes_downloaded=bytes_downloaded, + files_deleted=files_deleted, + files_created=created, + repair=repair, + dry_run=dry_run, + completed_at=datetime.now().astimezone().isoformat(), + ) + + +def _parse_snapshot( + region: str, + server: str, + depot_manifest: Mapping[str, object], + build_manifest: Mapping[str, object], +) -> DepotSnapshot: + build = _positive_int(depot_manifest.get("Build"), "depot Build") + build_manifest_build = _positive_int(build_manifest.get("b"), "build manifest b") + if build_manifest_build != build: + raise DepotError( + f"depot build {build} does not match build manifest {build_manifest_build}" + ) + name = _string(depot_manifest.get("Name"), "depot Name") + version = _string(depot_manifest.get("BuildVersionText"), "depot BuildVersionText") + files_value = build_manifest.get("f") + if not isinstance(files_value, list): + raise DepotError("build manifest f must be an array") + files: list[DepotFile] = [] + seen_paths: set[str] = set() + for value in files_value: + if not isinstance(value, dict): + raise DepotError("build manifest contains a non-object file entry") + path = _normalise_relative_path(value.get("p"), "file path") + if path.casefold() in seen_paths: + raise DepotError(f"build manifest has duplicate file path: {path}") + seen_paths.add(path.casefold()) + file_build = _positive_int(value.get("b"), f"file build for {path}") + size = _non_negative_int(value.get("s"), f"file size for {path}") + sha1 = _string(value.get("h"), f"file hash for {path}") + if not SHA1_PATTERN.fullmatch(sha1): + raise DepotError(f"invalid SHA-1 hash for {path}") + files.append(DepotFile(path, file_build, size, sha1.casefold())) + + deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths) + create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing")) + return DepotSnapshot( + region=region, + server=server, + name=name, + build=build, + version=version, + files=tuple(files), + deleted_files=deleted, + create_if_missing=create_if_missing, + ) + + +def _parse_deleted_paths(value: object, known_paths: set[str]) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list): + raise DepotError("build manifest df must be an array") + paths: list[str] = [] + seen: set[str] = set() + for item in value: + path = _normalise_relative_path(item, "deleted file path") + folded = path.casefold() + if folded in known_paths: + raise DepotError(f"manifest lists a file as both present and deleted: {path}") + if folded in seen: + continue + seen.add(folded) + paths.append(path) + return tuple(paths) + + +def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]: + if value is None: + return () + if not isinstance(value, list): + raise DepotError("CreateIfMissing must be an array") + entries: list[CreateIfMissing] = [] + seen: set[str] = set() + for item in value: + if not isinstance(item, dict): + raise DepotError("CreateIfMissing contains a non-object entry") + path = _normalise_relative_path(item.get("Path"), "CreateIfMissing path") + folded = path.casefold() + if folded in seen: + raise DepotError(f"CreateIfMissing has duplicate path: {path}") + seen.add(folded) + content = _string(item.get("Content"), f"CreateIfMissing content for {path}") + entry = CreateIfMissing(path, content) + _decode_create_if_missing(entry) + entries.append(entry) + return tuple(entries) + + +def _decode_create_if_missing(entry: CreateIfMissing) -> bytes: + try: + content = b64decode(entry.content_base64, validate=True) + except (ValueError, BinAsciiError) as exc: + raise DepotError(f"invalid base64 create-if-missing entry: {entry.path}") from exc + if len(content) > MAX_CREATE_IF_MISSING_BYTES: + raise DepotError(f"create-if-missing entry is too large: {entry.path}") + return content + + +def _server_candidates( + configuration: Mapping[str, object] | None, + preferred_region: str, +) -> tuple[tuple[str, str], ...]: + entries: list[tuple[str, str]] = [] + if configuration is not None: + regions = configuration.get("Regions") + default_region = configuration.get("DefaultRegion") + if isinstance(regions, list): + ordered = [preferred_region] + if isinstance(default_region, str) and default_region not in ordered: + ordered.append(default_region) + for region_name in ordered: + for region in regions: + if not isinstance(region, dict) or region.get("Name") != region_name: + continue + servers = region.get("Servers") + if isinstance(servers, list): + for server in servers: + if not isinstance(server, str): + continue + try: + entries.append((region_name, _normalise_server(server))) + except DepotError: + continue + seen: set[str] = set() + unique: list[tuple[str, str]] = [] + for region, server in [*entries, *(("Fallback", value) for value in FALLBACK_SERVERS)]: + if server in seen: + continue + seen.add(server) + unique.append((region, server)) + return tuple(unique) + + +def _normalise_server(value: str) -> str: + server = value.strip().strip("/") + if not server or "://" in server or any(character.isspace() for character in server): + raise DepotError(f"invalid CDN server: {value!r}") + parsed = urlparse(f"https://{server}") + if not parsed.netloc or parsed.query or parsed.fragment or "@" in parsed.netloc: + raise DepotError(f"invalid CDN server: {value!r}") + parts = PurePosixPath(parsed.path).parts + if any(part in (".", "..") for part in parts): + raise DepotError(f"invalid CDN server path: {value!r}") + return f"{parsed.netloc}{parsed.path.rstrip('/')}" + + +def _depot_manifest_url(server: str) -> str: + return f"https://{server}/depots/{DEPOT_ID}/manifest.json" + + +def _build_manifest_url(server: str, build: int) -> str: + return f"https://{server}/depots/{DEPOT_ID}/builds/{build}.json" + + +def _file_url(server: str, file: DepotFile) -> str: + return ( + f"https://{server}/depots/{DEPOT_ID}/builds/{file.build}/" + f"{quote(file.path, safe='/')}" + ) + + +def _ordered_download_servers( + primary: str, + candidates: Iterable[str], +) -> tuple[str, ...]: + ordered: list[str] = [] + seen: set[str] = set() + for value in (primary, *candidates): + server = _normalise_server(value) + if server in seen: + continue + seen.add(server) + ordered.append(server) + return tuple(ordered) + + +def _download_partial_path(destination: Path) -> Path: + return destination.with_name(f".{destination.name}.ptlinux.partial") + + +def _recover_crlf_manifest_file( + temporary: Path, + destination: Path, + file: DepotFile, +) -> bool: + """Restore publisher-normalized legacy text only on an exact hash match.""" + + try: + if temporary.stat().st_size > MAX_LINE_ENDING_RECOVERY_BYTES: + return False + data = temporary.read_bytes() + except OSError as exc: + raise DepotError(f"could not inspect downloaded {file.path}: {exc}") from exc + if b"\r" in data: + return False + recovered = data.replace(b"\n", b"\r\n") + if len(recovered) != file.size: + return False + if ( + hashlib.sha1(recovered, usedforsecurity=False).hexdigest().casefold() + != file.sha1.casefold() + ): + return False + recovered_temporary = destination.with_name( + f".{destination.name}.{os.getpid()}.{uuid4().hex}.recovered" + ) + try: + with recovered_temporary.open("xb") as handle: + handle.write(recovered) + handle.flush() + os.fsync(handle.fileno()) + recovered_temporary.replace(destination) + except BaseException: + recovered_temporary.unlink(missing_ok=True) + raise + return True + + +def _prepare_game_root(game_directory: Path, *, create: bool) -> Path: + root = game_directory.expanduser() + if root.is_symlink(): + raise DepotError(f"managed game directory must not be a symbolic link: {root}") + if root.exists() and not root.is_dir(): + raise DepotError(f"managed game path is not a directory: {root}") + if create: + root.mkdir(parents=True, exist_ok=True) + return root.resolve(strict=False) + + +def _safe_target(root: Path, relative_path: str) -> Path: + parts = _relative_parts(relative_path, "depot path") + target = root.joinpath(*parts) + try: + target.resolve(strict=False).relative_to(root) + except ValueError as exc: + raise DepotError(f"depot path escapes the managed game directory: {relative_path}") from exc + return target + + +def _ensure_target_parent(root: Path, target: Path) -> None: + try: + relative_parent = target.parent.relative_to(root) + except ValueError as exc: + raise DepotError(f"target escapes managed game directory: {target}") from exc + cursor = root + for part in relative_parent.parts: + cursor = cursor / part + if cursor.is_symlink(): + raise DepotError(f"refusing to traverse symbolic link in game tree: {cursor}") + if cursor.exists(): + if not cursor.is_dir(): + raise DepotError(f"cannot create game directory over file: {cursor}") + else: + try: + cursor.mkdir() + except FileExistsError: + # Another download worker can create a shared parent after + # ``exists()`` and before ``mkdir()``. Accept only the directory + # that the losing worker expected; never accept a file or symlink. + if cursor.is_symlink(): + raise DepotError( + f"refusing to traverse symbolic link in game tree: {cursor}" + ) + if not cursor.is_dir(): + raise DepotError(f"cannot create game directory over file: {cursor}") + except OSError as exc: + raise DepotError(f"cannot create game directory {cursor}: {exc}") from exc + + +def _normalise_relative_path(value: object, field: str) -> str: + if not isinstance(value, str): + raise DepotError(f"{field} must be a string") + parts = _relative_parts(value, field) + return "/".join(parts) + + +def _relative_parts(value: str, field: str) -> tuple[str, ...]: + if not value or "\x00" in value: + raise DepotError(f"invalid {field}") + normalised = value.replace("\\", "/") + if normalised.startswith("/") or normalised.startswith("//"): + raise DepotError(f"{field} must be relative: {value!r}") + parts = PurePosixPath(normalised).parts + if not parts or any(part in ("", ".", "..") or ":" in part for part in parts): + raise DepotError(f"invalid {field}: {value!r}") + return tuple(parts) + + +def _positive_int(value: object, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise DepotError(f"{field} must be a positive integer") + return value + + +def _non_negative_int(value: object, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise DepotError(f"{field} must be a non-negative integer") + return value + + +def _string(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise DepotError(f"{field} must be a non-empty string") + return value + + +def _sha1(path: Path) -> str: + digest = hashlib.sha1(usedforsecurity=False) + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""): + digest.update(chunk) + except OSError as exc: + raise DepotError(f"cannot hash {path}: {exc}") from exc + 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 DepotError(f"invalid Content-Length: {value!r}") from exc + if length < 0: + raise DepotError(f"invalid Content-Length: {value!r}") + return length + + +def _content_range(value: str | None) -> tuple[int, int, int]: + if value is None: + raise DepotError("206 response omitted Content-Range") + match = CONTENT_RANGE_PATTERN.fullmatch(value.strip()) + if match is None: + raise DepotError(f"invalid Content-Range: {value!r}") + start, end, total = (int(part) for part in match.groups()) + if start > end or end >= total: + raise DepotError(f"invalid Content-Range: {value!r}") + return start, end, total + + +def _headers() -> dict[str, str]: + return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"} + + +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: + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def _emit_progress( + callback: ProgressCallback | None, + phase: str, + files_completed: int, + files_total: int, + bytes_completed: int, + bytes_total: int, + current_file: str | None, +) -> None: + if callback is not None: + callback( + SyncProgress( + phase, + files_completed, + files_total, + bytes_completed, + bytes_total, + current_file, + ) + ) diff --git a/ptlinux/desktop_entry.py b/ptlinux/desktop_entry.py new file mode 100644 index 0000000..31a1b7a --- /dev/null +++ b/ptlinux/desktop_entry.py @@ -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()) diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py new file mode 100644 index 0000000..edfc9f1 --- /dev/null +++ b/ptlinux/doctor.py @@ -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 diff --git a/ptlinux/download.py b/ptlinux/download.py new file mode 100644 index 0000000..1c4bfff --- /dev/null +++ b/ptlinux/download.py @@ -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(" 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(" 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 diff --git a/ptlinux/gamebox.py b/ptlinux/gamebox.py new file mode 100644 index 0000000..d8da0bb --- /dev/null +++ b/ptlinux/gamebox.py @@ -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 diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py new file mode 100644 index 0000000..eaff450 --- /dev/null +++ b/ptlinux/gamebox_runtime.py @@ -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()) diff --git a/ptlinux/importer.py b/ptlinux/importer.py new file mode 100644 index 0000000..7997542 --- /dev/null +++ b/ptlinux/importer.py @@ -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) diff --git a/ptlinux/install_status.py b/ptlinux/install_status.py new file mode 100644 index 0000000..e90cd4a --- /dev/null +++ b/ptlinux/install_status.py @@ -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 diff --git a/ptlinux/launcher-update-public.pem b/ptlinux/launcher-update-public.pem new file mode 100644 index 0000000..a223c60 --- /dev/null +++ b/ptlinux/launcher-update-public.pem @@ -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----- diff --git a/ptlinux/launcher.py b/ptlinux/launcher.py new file mode 100644 index 0000000..21eaf8f --- /dev/null +++ b/ptlinux/launcher.py @@ -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) diff --git a/ptlinux/launcher_wrapper.py b/ptlinux/launcher_wrapper.py new file mode 100644 index 0000000..f9421f0 --- /dev/null +++ b/ptlinux/launcher_wrapper.py @@ -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()) diff --git a/ptlinux/locking.py b/ptlinux/locking.py new file mode 100644 index 0000000..38404b6 --- /dev/null +++ b/ptlinux/locking.py @@ -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) diff --git a/ptlinux/news.py b/ptlinux/news.py new file mode 100644 index 0000000..3ad24fe --- /dev/null +++ b/ptlinux/news.py @@ -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) diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py new file mode 100644 index 0000000..4a84117 --- /dev/null +++ b/ptlinux/overlay.py @@ -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) diff --git a/ptlinux/paths.py b/ptlinux/paths.py new file mode 100644 index 0000000..b28c513 --- /dev/null +++ b/ptlinux/paths.py @@ -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) diff --git a/ptlinux/proton_compat.py b/ptlinux/proton_compat.py new file mode 100644 index 0000000..c1dcdf7 --- /dev/null +++ b/ptlinux/proton_compat.py @@ -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() diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py new file mode 100644 index 0000000..76694a2 --- /dev/null +++ b/ptlinux/runtime.py @@ -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) diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py new file mode 100644 index 0000000..e5e0d60 --- /dev/null +++ b/ptlinux/self_update.py @@ -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) diff --git a/ptlinux/settings.py b/ptlinux/settings.py new file mode 100644 index 0000000..bf8a75c --- /dev/null +++ b/ptlinux/settings.py @@ -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 diff --git a/ptlinux/ui.py b/ptlinux/ui.py new file mode 100644 index 0000000..09b4a71 --- /dev/null +++ b/ptlinux/ui.py @@ -0,0 +1,1753 @@ +"""Native PristonTale EU dashboard; Wine never hosts the updater UI.""" + +from __future__ import annotations + +import base64 +from collections import deque +import json +import math +import queue +import subprocess +import sys +import threading +import time +import webbrowser +from typing import Callable, Mapping + +from . import __version__ +from .depot import DepotClient, DepotError, DepotSnapshot +from .install_status import ( + GameInstallationStatus, + PrimaryAction, + inspect_game_installation, +) +from .news import ( + Broadcast, + NewsArticle, + fetch_latest_broadcast, + fetch_news, + fetch_news_image, + fetch_official_logo, +) +from .paths import AppPaths +from .settings import Settings, ensure_settings, save_settings +from .self_update import LauncherUpdateCheck, check_for_launcher_update + + +MAX_NEWS_CARDS = 3 +WINDOW_SIZE = "1000x750" +NEWS_CARD_HEIGHT = 242 +NEWS_IMAGE_WIDTH = 292 +NEWS_IMAGE_HEIGHT = 118 +LAUNCH_STAGE_TOTAL = 4 +EVENT_BATCH_SIZE = 48 +POLL_INTERVAL_MILLISECONDS = 33 +TRANSFER_RATE_WINDOW_SECONDS = 3.0 +TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 +TRANSFER_RATE_IDLE_SECONDS = 2.0 + + +class DashboardError(RuntimeError): + """Raised when the native dashboard cannot be displayed.""" + + +class ProgressCoalescer: + """Keep only the latest cross-thread progress event for the Tk loop. + + The depot has concurrent download workers. Passing every 512 KiB byte update + through the GUI queue lets output overwhelm Tk, so the dashboard displays the + most recent snapshot once per UI frame instead. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._latest: dict[str, object] | None = None + + def publish(self, event: Mapping[str, object]) -> None: + with self._lock: + self._latest = dict(event) + + def take(self) -> dict[str, object] | None: + with self._lock: + latest = self._latest + self._latest = None + return latest + + def clear(self) -> None: + with self._lock: + self._latest = None + + +class TransferRateEstimator: + """Estimate a stable byte rate from cumulative network-transfer samples.""" + + def __init__( + self, + window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS, + minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, + ) -> None: + if window_seconds <= 0: + raise ValueError("transfer-rate window must be positive") + if minimum_interval_seconds < 0: + raise ValueError("transfer-rate minimum interval must not be negative") + self._window_seconds = window_seconds + self._minimum_interval_seconds = minimum_interval_seconds + self._samples: deque[tuple[float, int]] = deque() + + def reset(self) -> None: + self._samples.clear() + + def update( + self, + transferred_bytes: int, + *, + timestamp: float | None = None, + ) -> float | None: + """Record one cumulative sample and return bytes per second when stable.""" + + if transferred_bytes < 0: + raise ValueError("transferred bytes must not be negative") + now = time.monotonic() if timestamp is None else timestamp + if self._samples: + last_time, last_bytes = self._samples[-1] + if now <= last_time or transferred_bytes < last_bytes: + self.reset() + self._samples.append((now, transferred_bytes)) + cutoff = now - self._window_seconds + while len(self._samples) > 2 and self._samples[1][0] <= cutoff: + self._samples.popleft() + if len(self._samples) < 2: + return None + first_time, first_bytes = self._samples[0] + elapsed = now - first_time + if elapsed < self._minimum_interval_seconds: + return None + return max(0.0, (transferred_bytes - first_bytes) / elapsed) + + def is_idle( + self, + threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS, + *, + timestamp: float | None = None, + ) -> bool: + """Return whether no cumulative sample arrived within the threshold.""" + + if threshold_seconds < 0: + raise ValueError("idle threshold must not be negative") + if not self._samples: + return False + now = time.monotonic() if timestamp is None else timestamp + return now - self._samples[-1][0] >= threshold_seconds + + +def run_dashboard(paths: AppPaths) -> None: + try: + import tkinter as tk + from tkinter import filedialog, messagebox, ttk + except ImportError as exc: + raise DashboardError( + "Python Tk is missing. Install your distribution's python3-tk package " + "or use the pristontale-eu command-line interface." + ) from exc + + paths.create_base_directories() + ensure_settings(paths) + root = tk.Tk() + root.title("PristonTale EU — Linux Launcher") + root.geometry(WINDOW_SIZE) + root.resizable(False, False) + root.configure(background="#0c0908") + + style = ttk.Style(root) + style.theme_use("clam") + style.configure("Root.TFrame", background="#0c0908") + style.configure("Surface.TFrame", background="#191311") + style.configure("Hero.TFrame", background="#24170f") + style.configure("Card.TFrame", background="#211916") + style.configure( + "Brand.TLabel", + background="#0c0908", + foreground="#f5d18b", + font=("Georgia", 21, "bold"), + ) + style.configure( + "Tagline.TLabel", + background="#0c0908", + foreground="#a8998a", + font=("Sans", 8), + ) + style.configure( + "Section.TLabel", + background="#191311", + foreground="#f0d7a2", + font=("Sans", 9, "bold"), + ) + style.configure( + "HeroTitle.TLabel", + background="#24170f", + foreground="#fff2d3", + font=("Georgia", 17, "bold"), + ) + style.configure( + "HeroText.TLabel", + background="#24170f", + foreground="#d6c6b3", + font=("Sans", 10), + ) + style.configure( + "Status.TLabel", + background="#191311", + foreground="#dfd1c1", + font=("Sans", 9), + ) + style.configure( + "Muted.TLabel", + background="#191311", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "Speed.TLabel", + background="#191311", + foreground="#e5b66c", + font=("Monospace", 8, "bold"), + ) + style.configure( + "SettingsTitle.TLabel", + background="#191311", + foreground="#fff0d2", + font=("Georgia", 16, "bold"), + ) + style.configure( + "SettingsText.TLabel", + background="#191311", + foreground="#d8cbbd", + font=("Sans", 9), + ) + style.configure( + "SettingsHint.TLabel", + background="#191311", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "SettingsCard.TLabelframe", + background="#191311", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#120e0c", + borderwidth=1, + relief="solid", + ) + style.configure( + "SettingsCard.TLabelframe.Label", + background="#191311", + foreground="#f0d7a2", + font=("Sans", 9, "bold"), + ) + style.configure("SettingsCard.TFrame", background="#191311") + style.configure("SettingsFooter.TFrame", background="#100d0c") + style.configure( + "SettingsFooter.TLabel", + background="#100d0c", + foreground="#aa9a8b", + font=("Sans", 8), + ) + style.configure( + "Settings.TEntry", + fieldbackground="#100d0c", + foreground="#f8ead2", + insertcolor="#f8fafc", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#4a3528", + ) + style.configure( + "Settings.TCombobox", + fieldbackground="#100d0c", + background="#211916", + foreground="#f8ead2", + arrowcolor="#e5b66c", + bordercolor="#4a3528", + ) + style.map( + "Settings.TCombobox", + fieldbackground=[("readonly", "#100d0c")], + foreground=[("readonly", "#f8ead2")], + ) + style.configure( + "Settings.TCheckbutton", + background="#191311", + foreground="#d8cbbd", + font=("Sans", 9), + ) + style.map( + "Settings.TCheckbutton", + background=[("active", "#191311")], + foreground=[("active", "#fff0d2")], + ) + style.configure( + "PreloaderStatus.TLabel", + background="#0c0908", + foreground="#dfd1c1", + font=("Sans", 10), + ) + style.configure( + "PreloaderMuted.TLabel", + background="#0c0908", + foreground="#aa9a8b", + font=("Sans", 9), + ) + style.configure( + "NewsTitle.TLabel", + background="#211916", + foreground="#fff0d2", + font=("Sans", 9, "bold"), + ) + style.configure( + "NewsMeta.TLabel", + background="#211916", + foreground="#d8aa61", + font=("Sans", 9, "bold"), + ) + style.configure( + "NewsBody.TLabel", + background="#211916", + foreground="#c9baaa", + font=("Sans", 8), + ) + style.configure( + "Play.TButton", + background="#d98927", + foreground="#fff8e9", + font=("Sans", 10, "bold"), + padding=(20, 10), + bordercolor="#f4bd61", + borderwidth=1, + relief="flat", + ) + style.map( + "Play.TButton", + background=[("active", "#efaa4c"), ("disabled", "#6c4826")], + foreground=[("disabled", "#bca889")], + ) + style.configure( + "Secondary.TButton", + background="#0b0a09", + foreground="#f2b657", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#d89332", + borderwidth=1, + relief="flat", + ) + style.map( + "Secondary.TButton", + background=[("active", "#271b10"), ("disabled", "#171412")], + foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], + ) + style.configure( + "Activity.TButton", + background="#0c0908", + foreground="#f8fafc", + font=("Monospace", 8, "bold"), + padding=(3, 2), + borderwidth=0, + relief="flat", + ) + style.map( + "Activity.TButton", + background=[("active", "#24170f")], + foreground=[("active", "#ffffff")], + ) + style.configure( + "News.TButton", + background="#211916", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + padding=(0, 3), + ) + style.map("News.TButton", foreground=[("active", "#ffe0a0")]) + style.configure( + "Depot.Horizontal.TProgressbar", + background="#d68c34", + troughcolor="#34251f", + bordercolor="#34251f", + lightcolor="#e4a34c", + darkcolor="#b66c25", + ) + + frame = ttk.Frame(root, style="Root.TFrame", padding=12) + frame.pack(fill="both", expand=True) + + header = ttk.Frame(frame, style="Root.TFrame") + header.pack(fill="x", pady=(0, 8)) + logo = tk.Canvas( + header, + width=42, + height=42, + background="#0c0908", + highlightthickness=0, + borderwidth=0, + ) + logo.pack(side="left", padx=(0, 9)) + brand = ttk.Frame(header, style="Root.TFrame") + brand.pack(side="left", fill="x", expand=True) + ttk.Label(brand, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w") + ttk.Label( + brand, + text="PRISTONTALE EU · NATIVE LINUX LAUNCHER", + style="Tagline.TLabel", + ).pack(anchor="w", pady=(1, 0)) + version = tk.StringVar( + value=f"Launcher {__version__} · checking official game build…" + ) + ttk.Label(header, textvariable=version, style="Tagline.TLabel").pack( + side="right", + anchor="e", + ) + + controls = ttk.Frame(frame, style="Root.TFrame") + controls.pack(fill="x", pady=(8, 8)) + controls.columnconfigure(0, weight=1, uniform="actions") + controls.columnconfigure(1, weight=1, uniform="actions") + game_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) + game_actions.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) + ttk.Label(game_actions, text="GAME FILES", style="Section.TLabel").pack(anchor="w") + game_button_row = ttk.Frame(game_actions, style="Surface.TFrame") + game_button_row.pack(anchor="w", fill="x", pady=(6, 0)) + update_button = ttk.Button( + game_button_row, + text="UPDATE", + style="Secondary.TButton", + ) + update_button.pack(side="left") + repair_button = ttk.Button( + game_button_row, + text="REPAIR", + style="Secondary.TButton", + ) + repair_button.pack(side="left", padx=(8, 0)) + import_button = ttk.Button( + game_button_row, + text="IMPORT GAME", + style="Secondary.TButton", + ) + import_button.pack(side="left", padx=(8, 0)) + + system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) + system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) + ttk.Label(system_actions, text="LAUNCHER", style="Section.TLabel").pack(anchor="w") + system_button_row = ttk.Frame(system_actions, style="Surface.TFrame") + system_button_row.pack(anchor="w", fill="x", pady=(6, 0)) + doctor_button = ttk.Button(system_button_row, text="SYSTEM CHECK", style="Secondary.TButton") + doctor_button.pack(side="left") + settings_button = ttk.Button(system_button_row, text="OPEN SETTINGS", style="Secondary.TButton") + settings_button.pack(side="left", padx=(8, 0)) + launcher_update_button = ttk.Button( + system_button_row, + text=f"LAUNCHER {__version__}", + style="Secondary.TButton", + ) + launcher_update_button.pack(side="left", padx=(8, 0)) + + activity_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 7)) + activity_panel.pack(fill="x", pady=(0, 8)) + activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame") + activity_copy.pack(side="left", fill="x", expand=True, padx=(0, 12)) + status = tk.StringVar(value="Ready") + progress_text = tk.StringVar(value="Click Play to start your journey") + speed_text = tk.StringVar(value="") + ttk.Label(activity_copy, textvariable=status, style="Status.TLabel").pack(anchor="w") + progress_bar = ttk.Progressbar( + activity_copy, + style="Depot.Horizontal.TProgressbar", + mode="determinate", + maximum=1, + value=0, + ) + progress_bar.pack(fill="x", pady=(5, 2)) + progress_details = ttk.Frame(activity_copy, style="Surface.TFrame") + progress_details.pack(fill="x") + ttk.Label( + progress_details, + textvariable=speed_text, + style="Speed.TLabel", + width=15, + anchor="e", + ).pack(side="right") + ttk.Label( + progress_details, + textvariable=progress_text, + style="Muted.TLabel", + anchor="w", + ).pack(side="left", fill="x", expand=True) + play_button = ttk.Button(activity_panel, text="PLAY", style="Play.TButton") + play_button.pack(side="right", fill="y") + + news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) + news_panel.pack(fill="x", pady=(0, 8), before=controls) + news_heading = ttk.Frame(news_panel, style="Surface.TFrame") + news_heading.pack(fill="x") + ttk.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left") + ttk.Label( + news_heading, + text="OFFICIAL FEED", + style="Muted.TLabel", + ).pack(side="right") + news_canvas = tk.Canvas( + news_panel, + background="#191311", + highlightthickness=0, + borderwidth=0, + height=252, + ) + news_canvas.pack(fill="both", expand=True, pady=(6, 0)) + news_content = ttk.Frame(news_canvas, style="Surface.TFrame") + news_window = news_canvas.create_window((0, 0), window=news_content, anchor="nw") + + broadcast_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) + broadcast_panel.pack(fill="x", pady=(0, 8), before=news_panel) + broadcast_heading = ttk.Frame(broadcast_panel, style="Surface.TFrame") + broadcast_heading.pack(fill="x") + ttk.Label(broadcast_heading, text="LATEST BROADCAST", style="Section.TLabel").pack(side="left") + ttk.Label( + broadcast_heading, + text="OFFICIAL DISCORD", + style="Muted.TLabel", + ).pack(side="right") + broadcast_content = ttk.Frame(broadcast_panel, style="Surface.TFrame") + broadcast_content.pack(fill="x", pady=(6, 0)) + + activity_window = tk.Toplevel(root) + activity_window.title("PristonTale EU — Activity") + activity_window.geometry("800x400") + activity_window.resizable(False, False) + activity_window.configure(background="#0c0908") + log = tk.Text( + activity_window, + background="#100d0c", + foreground="#d8cbbd", + insertbackground="#f8fafc", + relief="flat", + font=("Monospace", 9), + padx=10, + pady=8, + state="disabled", + ) + log.pack(fill="both", expand=True, padx=10, pady=10) + activity_window.withdraw() + log_toggle = tk.Button( + frame, + text=">_", + background="#0c0908", + foreground="#f8fafc", + activebackground="#24170f", + activeforeground="#ffffff", + font=("Monospace", 8, "bold"), + padx=3, + pady=2, + borderwidth=0, + highlightthickness=0, + relief="flat", + ) + log_toggle.place(x=0, rely=1.0, anchor="sw") + + events: queue.Queue[tuple[str, object]] = queue.Queue() + progress_updates = ProgressCoalescer() + transfer_rate = TransferRateEstimator() + news_image_targets: dict[int, tk.Canvas] = {} + news_image_references: dict[int, tk.PhotoImage] = {} + logo_image_references: list[tk.PhotoImage] = [] + preloader: tk.Toplevel | None = None + preloader_status: tk.StringVar | None = None + preloader_progress: ttk.Progressbar | None = None + settings_window: tk.Toplevel | None = None + operation_active = False + operation_started_at: float | None = None + operation_stage = 0 + operation_stage_total = 0 + operation_message = "" + last_elapsed_second = -1 + official_snapshot: DepotSnapshot | None = None + launcher_update_check: LauncherUpdateCheck | None = None + buttons = ( + play_button, + update_button, + repair_button, + doctor_button, + settings_button, + import_button, + launcher_update_button, + ) + + def append_log(value: str) -> None: + log.configure(state="normal") + log.insert("end", value.rstrip() + "\n") + log.see("end") + log.configure(state="disabled") + + def toggle_log() -> None: + if activity_window.state() == "withdrawn": + activity_window.deiconify() + activity_window.lift() + activity_window.focus_set() + else: + activity_window.withdraw() + + def hide_activity_window() -> None: + activity_window.withdraw() + + activity_window.protocol("WM_DELETE_WINDOW", hide_activity_window) + + def set_busy(value: bool) -> None: + new_state = "disabled" if value else "normal" + for button in buttons: + button.configure(state=new_state) + + def update_progress(event: Mapping[str, object]) -> None: + phase = event.get("phase") + files_completed = _as_int(event.get("files_completed")) + files_total = _as_int(event.get("files_total")) + bytes_completed = _as_int(event.get("bytes_completed")) + bytes_total = _as_int(event.get("bytes_total")) + current_file = event.get("current_file") + if phase == "manifest": + transfer_rate.reset() + speed_text.set("") + set_operation_stage(1, 3, "Checking official CDN manifests…") + return + progress_bar.stop() + progress_bar.configure(mode="determinate") + if bytes_total: + progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total)) + progress_text.set( + f"{files_completed}/{files_total} files · " + f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}" + ) + if phase == "downloading": + bytes_transferred = _as_int(event.get("bytes_transferred")) + rate = transfer_rate.update(bytes_transferred) + speed_text.set(format_transfer_rate(rate) if rate is not None else "0 B/s") + else: + transfer_rate.reset() + speed_text.set("") + else: + progress_bar.configure(maximum=3, value=3) + progress_text.set("Game files are already up to date.") + transfer_rate.reset() + speed_text.set("") + if isinstance(current_file, str) and current_file: + status.set(f"Updating {current_file}") + elif phase == "complete": + status.set("Game files are up to date and verified.") + + def activity_text() -> str: + if operation_started_at is None or operation_stage_total <= 0: + return operation_message + elapsed = int(time.monotonic() - operation_started_at) + return ( + f"Step {operation_stage}/{operation_stage_total} · " + f"{operation_message} · {elapsed}s elapsed" + ) + + def set_operation_stage(step: int, total: int, message: str) -> None: + nonlocal operation_stage, operation_stage_total, operation_message + operation_stage = min(max(step, 0), total) + operation_stage_total = max(total, 1) + operation_message = message + transfer_rate.reset() + speed_text.set("") + progress_bar.stop() + progress_bar.configure( + mode="determinate", + maximum=operation_stage_total, + value=operation_stage, + ) + status.set(message) + progress_text.set(activity_text()) + set_preloader_status(message, operation_stage, operation_stage_total) + + def update_elapsed_indicator() -> None: + nonlocal last_elapsed_second + if ( + not operation_active + or operation_started_at is None + or operation_stage_total != LAUNCH_STAGE_TOTAL + ): + return + elapsed = int(time.monotonic() - operation_started_at) + if elapsed == last_elapsed_second: + return + last_elapsed_second = elapsed + progress_text.set(activity_text()) + set_preloader_status(operation_message, operation_stage, operation_stage_total) + + def show_game_preloader() -> None: + nonlocal preloader, preloader_status, preloader_progress + if preloader is not None and preloader.winfo_exists(): + return + window = tk.Toplevel(root) + preloader = window + window.title("Launching PristonTale EU") + window.configure(background="#0c0908") + window.resizable(False, False) + window.protocol("WM_DELETE_WINDOW", lambda: None) + window.transient(root) + window.attributes("-topmost", True) + status_variable = tk.StringVar(value="Starting launcher process…") + preloader_status = status_variable + content = ttk.Frame(window, style="Root.TFrame", padding=26) + content.pack(fill="both", expand=True) + ttk.Label(content, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w") + ttk.Label(content, text="LAUNCHING THE CLIENT", style="Tagline.TLabel").pack( + anchor="w", + pady=(2, 18), + ) + ttk.Label( + content, + text="Please wait while the game prepares its window.", + style="PreloaderStatus.TLabel", + ).pack(anchor="w") + ttk.Label(content, textvariable=status_variable, style="PreloaderMuted.TLabel").pack( + anchor="w", + pady=(4, 10), + ) + progress_widget = ttk.Progressbar( + content, + style="Depot.Horizontal.TProgressbar", + mode="determinate", + maximum=LAUNCH_STAGE_TOTAL, + value=1, + ) + preloader_progress = progress_widget + progress_widget.pack(fill="x") + window.update_idletasks() + width = window.winfo_reqwidth() + height = window.winfo_reqheight() + x = (window.winfo_screenwidth() - width) // 2 + y = (window.winfo_screenheight() - height) // 2 + window.geometry(f"{width}x{height}+{x}+{y}") + window.focus_force() + + def set_preloader_status(value: str, step: int | None = None, total: int | None = None) -> None: + if preloader_status is not None: + if step is not None and total is not None: + preloader_status.set(f"Step {step} of {total} · {value}") + else: + preloader_status.set(value) + if preloader_progress is not None and step is not None and total is not None: + preloader_progress.configure(maximum=total, value=step) + + def close_game_preloader() -> None: + nonlocal preloader, preloader_status, preloader_progress + if preloader is not None and preloader.winfo_exists(): + preloader.destroy() + preloader = None + preloader_status = None + preloader_progress = None + + def run_operation( + label: str, + command: list[str], + *, + show_preloader: bool = False, + restart_on_success: bool = False, + ) -> None: + nonlocal operation_active + nonlocal operation_started_at + nonlocal operation_stage + nonlocal operation_stage_total + nonlocal operation_message + nonlocal last_elapsed_second + operation_active = True + operation_started_at = time.monotonic() + operation_stage = 0 + operation_stage_total = 0 + operation_message = "" + last_elapsed_second = -1 + progress_updates.clear() + transfer_rate.reset() + speed_text.set("") + set_busy(True) + status.set(label) + progress_text.set("Waiting for the operation to report progress…") + if show_preloader: + show_game_preloader() + set_operation_stage(1, LAUNCH_STAGE_TOTAL, "Starting launcher process…") + else: + progress_bar.stop() + progress_bar.configure(mode="determinate", maximum=3, value=0) + append_log(f"$ {' '.join(command)}") + + def worker() -> None: + try: + process = subprocess.Popen( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + except OSError as process_error: + events.put(("output", f"Could not start operation: {process_error}\n")) + events.put(("done", 1)) + return + if process.stdout is not None: + for line in process.stdout: + if line.startswith("STATUS "): + try: + parsed_status = json.loads(line.removeprefix("STATUS ")) + except json.JSONDecodeError: + events.put(("output", line)) + else: + if isinstance(parsed_status, dict): + events.put(("operation_status", parsed_status)) + else: + events.put(("output", line)) + elif line.startswith("PROGRESS "): + try: + parsed = json.loads(line.removeprefix("PROGRESS ")) + except json.JSONDecodeError: + events.put(("output", line)) + else: + if isinstance(parsed, dict): + progress_updates.publish(parsed) + else: + events.put(("output", line)) + else: + events.put(("output", line)) + if show_preloader and line.startswith("Game window created;"): + events.put( + ("operation_status", { + "step": 4, + "total": LAUNCH_STAGE_TOTAL, + "message": "Game client started.", + }) + ) + events.put(("game_started", None)) + if show_preloader and line.startswith(("Game window ready:", "Rendered game frame:")): + events.put(("game_window_ready", None)) + returncode = process.wait() + events.put(("launcher_update_done" if restart_on_success else "done", returncode)) + + threading.Thread(target=worker, name="ptlinux-operation", daemon=True).start() + + def open_article(url: str) -> None: + webbrowser.open(url) + + def make_news_card_clickable(card: tk.Frame, url: str) -> None: + def enter(_event: object) -> None: + card.configure(background="#2a1e17", highlightbackground="#e4a34c", highlightthickness=1) + + def leave(_event: object) -> None: + card.configure(background="#211916", highlightbackground="#4a3528", highlightthickness=1) + + def open_card(_event: object) -> None: + open_article(url) + + def bind_widget(widget: object) -> None: + bind = getattr(widget, "bind") + configure = getattr(widget, "configure") + bind("", enter) + bind("", leave) + bind("", open_card) + configure(cursor="hand2") + for child in getattr(widget, "winfo_children")(): + bind_widget(child) + + bind_widget(card) + + def show_broadcast(broadcast: Broadcast | None) -> None: + for child in broadcast_content.winfo_children(): + child.destroy() + if broadcast is None: + ttk.Label( + broadcast_content, + text="No current broadcast is available.", + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=6) + return + card = tk.Frame( + broadcast_content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + padx=10, + pady=8, + ) + card.pack(fill="x") + meta = " · ".join( + part for part in (f"BROADCAST · {broadcast.author}", broadcast.published_at) if part + ) + tk.Label( + card, + text=meta, + background="#211916", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + ).pack(anchor="w") + tk.Label( + card, + text=broadcast.content, + background="#211916", + foreground="#e8ded2", + font=("Sans", 10), + wraplength=930, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x", pady=(3, 0)) + make_news_card_clickable(card, broadcast.url) + + def show_news(articles: tuple[NewsArticle, ...]) -> None: + news_image_targets.clear() + news_image_references.clear() + for child in news_content.winfo_children(): + child.destroy() + if not articles: + ttk.Label( + news_content, + text="Official news is temporarily unavailable. Your cached game can still play.", + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=8) + return + visible_articles = articles[:MAX_NEWS_CARDS] + for column in range(len(visible_articles)): + news_content.grid_columnconfigure(column, weight=1, uniform="news-card") + news_content.grid_rowconfigure(0, weight=1) + for index, article in enumerate(visible_articles): + card = tk.Frame( + news_content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + height=NEWS_CARD_HEIGHT, + padx=8, + pady=8, + ) + card.grid_propagate(False) + card.grid( + row=0, + column=index, + sticky="nsew", + padx=3, + pady=3, + ) + image_canvas = tk.Canvas( + card, + background="#211916", + width=NEWS_IMAGE_WIDTH, + height=NEWS_IMAGE_HEIGHT, + highlightthickness=0, + borderwidth=0, + ) + image_canvas.pack(fill="x") + image_canvas.create_text( + NEWS_IMAGE_WIDTH // 2, + NEWS_IMAGE_HEIGHT // 2, + text="Loading official artwork…" if article.image_url else "Official news", + fill="#a8998a", + font=("Sans", 9), + ) + if article.image_url: + news_image_targets[article.id] = image_canvas + ttk.Label( + card, + text=article.title, + style="NewsTitle.TLabel", + wraplength=NEWS_IMAGE_WIDTH, + justify="left", + ).pack(anchor="w", fill="x", pady=(6, 0)) + meta = " · ".join(part for part in (article.published_at, article.author) if part) + if meta: + ttk.Label(card, text=meta, style="NewsMeta.TLabel").pack(anchor="w", pady=(2, 4)) + tk.Label( + card, + text=article.summary, + background="#211916", + foreground="#c9baaa", + font=("Sans", 9), + wraplength=NEWS_IMAGE_WIDTH, + justify="left", + height=3, + anchor="nw", + ).pack(anchor="w", fill="x") + make_news_card_clickable(card, article.url) + load_news_images(visible_articles) + + def load_news_images(articles: tuple[NewsArticle, ...]) -> None: + def worker(news_article: NewsArticle) -> None: + image = fetch_news_image(news_article, paths.news_image_cache) + if image is not None: + events.put(("news_image", (news_article.id, image))) + + for article in articles: + if article.image_url is not None: + threading.Thread( + target=worker, + args=(article,), + name="ptlinux-news-image", + daemon=True, + ).start() + + def install_news_image(value: object) -> None: + if ( + not isinstance(value, tuple) + or len(value) != 2 + or not isinstance(value[0], int) + or not isinstance(value[1], bytes) + ): + return + article_id, image_data = value + target = news_image_targets.get(article_id) + if target is None or not target.winfo_exists(): + return + try: + image = tk.PhotoImage(data=base64.b64encode(image_data), format="png") + except tk.TclError: + target.delete("all") + target.create_text( + NEWS_IMAGE_WIDTH // 2, + NEWS_IMAGE_HEIGHT // 2, + text="Artwork unavailable", + fill="#a8998a", + font=("Sans", 8), + ) + return + target_width = target.winfo_width() + target_height = target.winfo_height() + if target_width <= 1: + target_width = NEWS_IMAGE_WIDTH + if target_height <= 1: + target_height = NEWS_IMAGE_HEIGHT + if image.width() < target_width or image.height() < target_height: + factor = max( + math.ceil(target_width / image.width()), + math.ceil(target_height / image.height()), + ) + image = image.zoom(factor, factor) + else: + factor = min(image.width() // target_width, image.height() // target_height) + if factor > 1: + image = image.subsample(factor, factor) + news_image_references[article_id] = image + target.delete("all") + target.create_image(target_width // 2, target_height // 2, image=image) + + def install_official_logo(value: object) -> None: + if not isinstance(value, bytes): + return + try: + image = tk.PhotoImage(data=base64.b64encode(value), format="png") + except tk.TclError: + return + factor = max(1, math.ceil(image.width() / 42), math.ceil(image.height() / 42)) + if factor > 1: + image = image.subsample(factor, factor) + logo_image_references.clear() + logo_image_references.append(image) + logo.delete("all") + logo.create_image(21, 21, image=image) + + def load_official_logo() -> None: + def worker() -> None: + image = fetch_official_logo(paths.logo_cache) + if image is not None: + events.put(("logo_image", image)) + + threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() + + def refresh_primary_action( + snapshot: DepotSnapshot | None = None, + *, + update_activity: bool = True, + ) -> GameInstallationStatus: + nonlocal official_snapshot + if snapshot is not None: + official_snapshot = snapshot + installation = inspect_game_installation( + paths, + official_snapshot.build if official_snapshot is not None else None, + ) + play_button.configure(text=installation.action.value) + if not update_activity or operation_active: + return installation + if installation.action is PrimaryAction.INSTALL: + status.set("Game is not installed.") + progress_text.set("Click Install to download and verify the official game files.") + elif installation.action is PrimaryAction.UPDATE: + status.set("A game update is available.") + official_build = installation.official_build + if official_build is None: + progress_text.set("Local game files need an official build check.") + elif installation.local_build is None: + progress_text.set( + f"Local files need verification · official depot {official_build}" + ) + else: + progress_text.set( + f"Installed depot {installation.local_build} · " + f"official depot {official_build}" + ) + else: + status.set("Ready") + progress_text.set("Click Play to start your journey") + return installation + + def refresh_remote_content() -> None: + def broadcast_worker() -> None: + events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache))) + + def news_worker() -> None: + events.put(("news", fetch_news(paths.news_cache))) + + def version_worker() -> None: + try: + snapshot = DepotClient().fetch_snapshot() + except DepotError as remote_error: + events.put(("version_error", str(remote_error))) + else: + events.put(("version", snapshot)) + + def launcher_update_worker() -> None: + try: + check = check_for_launcher_update() + except RuntimeError as update_error: + events.put(("launcher_update_error", str(update_error))) + else: + events.put(("launcher_update_check", check)) + + threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start() + threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start() + threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() + threading.Thread( + target=launcher_update_worker, + name="ptlinux-self-update", + daemon=True, + ).start() + + def finish_operation(returncode: int) -> None: + nonlocal operation_active, operation_started_at + operation_active = False + operation_started_at = None + progress_updates.clear() + transfer_rate.reset() + speed_text.set("") + progress_bar.stop() + close_game_preloader() + root.deiconify() + root.lift() + set_busy(False) + refresh_primary_action(update_activity=returncode == 0) + if returncode != 0: + status.set(f"Operation failed (exit {returncode})") + progress_text.set("See the activity log for details.") + + def poll_events() -> None: + nonlocal launcher_update_check + processed = 0 + try: + while processed < EVENT_BATCH_SIZE: + kind, value = events.get_nowait() + processed += 1 + if kind == "output" and isinstance(value, str): + append_log(value) + elif kind == "done" and isinstance(value, int): + finish_operation(value) + elif kind == "launcher_update_done" and isinstance(value, int): + finish_operation(value) + if value == 0: + append_log("Launcher update activated; restarting the dashboard.") + try: + subprocess.Popen( + command_for("gui"), + close_fds=True, + start_new_session=True, + ) + except OSError as restart_error: + messagebox.showerror( + "Launcher update", + f"The update was installed, but the launcher could not restart: {restart_error}", + parent=root, + ) + else: + root.destroy() + return + elif kind == "operation_status" and isinstance(value, Mapping): + step = _as_int(value.get("step")) + total = _as_int(value.get("total")) + message = value.get("message") + if step > 0 and total > 0 and isinstance(message, str) and message: + set_operation_stage(step, total, message) + elif kind in {"game_started", "game_window_ready"}: + progress_bar.stop() + status.set("Game client started.") + close_game_preloader() + root.iconify() + elif kind == "broadcast": + show_broadcast(value if isinstance(value, Broadcast) else None) + elif kind == "news" and isinstance(value, tuple): + show_news(value) + elif kind == "news_image": + install_news_image(value) + elif kind == "logo_image": + install_official_logo(value) + elif kind == "version" and isinstance(value, DepotSnapshot): + version.set( + f"Launcher {__version__} · {value.version} · depot {value.build}" + ) + refresh_primary_action(value) + elif kind == "version_error": + version.set( + f"Launcher {__version__} · game build check unavailable" + ) + refresh_primary_action() + elif kind == "launcher_update_check" and isinstance( + value, + LauncherUpdateCheck, + ): + launcher_update_check = value + if value.available and value.supported: + launcher_update_button.configure( + text=f"UPDATE TO {value.latest_version}", + state="normal", + ) + elif value.available: + launcher_update_button.configure( + text="MANUAL UPDATE NEEDED", + state="normal", + ) + else: + launcher_update_button.configure( + text=f"LAUNCHER {__version__}", + state="normal", + ) + append_log(value.reason) + elif kind == "launcher_update_error" and isinstance(value, str): + launcher_update_check = None + launcher_update_button.configure(text="CHECK LAUNCHER", state="normal") + append_log(f"Launcher update check unavailable: {value}") + except queue.Empty: + pass + if operation_active: + latest_progress = progress_updates.take() + if latest_progress is not None: + update_progress(latest_progress) + if speed_text.get() != "0 B/s" and transfer_rate.is_idle(): + speed_text.set("0 B/s") + update_elapsed_indicator() + root.after(POLL_INTERVAL_MILLISECONDS, poll_events) + + def command_for(*arguments: str) -> list[str]: + return [sys.executable, "-m", "ptlinux", *arguments] + + def run_launcher_update() -> None: + check = launcher_update_check + if check is None: + launcher_update_button.configure(text="CHECKING…", state="disabled") + refresh_remote_content() + return + if not check.available: + messagebox.showinfo( + "Launcher update", + check.reason, + parent=root, + ) + return + if not check.supported: + messagebox.showwarning( + "Manual launcher update required", + check.reason, + parent=root, + ) + return + if not messagebox.askyesno( + "Update launcher", + f"Install signed launcher version {check.latest_version} and restart now?", + parent=root, + ): + return + run_operation( + f"Installing launcher {check.latest_version}…", + command_for("launcher-update"), + restart_on_success=True, + ) + + launcher_update_button.configure(command=run_launcher_update) + + def run_primary_action() -> None: + installation = refresh_primary_action(update_activity=False) + if installation.action is PrimaryAction.PLAY: + run_operation( + "Starting PristonTale EU…", + command_for("play"), + show_preloader=True, + ) + elif installation.action is PrimaryAction.INSTALL: + run_operation( + "Installing official game files…", + command_for("update"), + ) + else: + run_operation( + "Synchronizing official game files…", + command_for("update"), + ) + + play_button.configure(command=run_primary_action) + update_button.configure( + command=lambda: run_operation( + "Synchronizing official game files…", + command_for("update"), + ) + ) + repair_button.configure( + command=lambda: run_operation( + "Checking every game file against the official depot…", + command_for("update", "--no-import", "--repair"), + ) + ) + doctor_button.configure( + command=lambda: run_operation("Checking the system…", command_for("doctor")) + ) + + def open_settings() -> None: + nonlocal settings_window + if settings_window is not None and settings_window.winfo_exists(): + settings_window.deiconify() + settings_window.lift() + settings_window.focus_set() + return + + current_settings = ensure_settings(paths) + dialog = tk.Toplevel(root) + settings_window = dialog + dialog.title("PristonTale EU — Settings") + dialog.geometry("840x680") + dialog.resizable(False, False) + dialog.configure(background="#0c0908") + dialog.transient(root) + + settings_header = tk.Frame(dialog, background="#24170f", height=118) + settings_header.pack(fill="x") + settings_header.pack_propagate(False) + tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y") + header_copy = tk.Frame(settings_header, background="#24170f") + header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) + tk.Label( + header_copy, + text="PRISTONTALE EU", + background="#24170f", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + ).pack(anchor="w") + tk.Label( + header_copy, + text="LAUNCHER SETTINGS", + background="#24170f", + foreground="#fff0d2", + font=("Georgia", 18, "bold"), + ).pack(anchor="w", pady=(1, 0)) + tk.Label( + header_copy, + text="Tune your display, rendering, and compatibility preferences.", + background="#24170f", + foreground="#c9baaa", + font=("Sans", 9), + ).pack(anchor="w", pady=(2, 0)) + + content = ttk.Frame(dialog, style="Root.TFrame", padding=(16, 14, 16, 16)) + content.pack(fill="both", expand=True) + content.columnconfigure(0, weight=1, uniform="settings-card") + content.columnconfigure(1, weight=1, uniform="settings-card") + + width_value = tk.StringVar(value=str(current_settings.width)) + height_value = tk.StringVar(value=str(current_settings.height)) + output_width_value = tk.StringVar(value=str(current_settings.output_width)) + output_height_value = tk.StringVar(value=str(current_settings.output_height)) + refresh_rate_value = tk.StringVar(value=str(current_settings.refresh_rate)) + fullscreen_value = tk.BooleanVar(value=current_settings.fullscreen) + borderless_value = tk.BooleanVar(value=current_settings.borderless) + scaler_value = tk.StringVar(value=current_settings.scaler) + filter_value = tk.StringVar(value=current_settings.filter) + runtime_value = tk.StringVar(value=current_settings.runtime) + proton_path_value = tk.StringVar(value=current_settings.proton_path or "") + game_source_value = tk.StringVar(value=current_settings.game_source or "") + + def add_dimensions( + parent: ttk.LabelFrame, + row: int, + label: str, + description: str, + width_variable: tk.StringVar, + height_variable: tk.StringVar, + ) -> None: + ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( + row=row, + column=0, + sticky="w", + ) + ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( + row=row, + column=1, + sticky="e", + ) + fields = ttk.Frame(parent, style="SettingsCard.TFrame") + fields.grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) + ttk.Label(fields, text="Width", style="SettingsHint.TLabel").pack(side="left") + ttk.Entry( + fields, + textvariable=width_variable, + width=8, + style="Settings.TEntry", + ).pack(side="left", padx=(5, 12)) + ttk.Label(fields, text="Height", style="SettingsHint.TLabel").pack(side="left") + ttk.Entry( + fields, + textvariable=height_variable, + width=8, + style="Settings.TEntry", + ).pack(side="left", padx=(5, 0)) + + def add_choice( + parent: ttk.LabelFrame, + row: int, + label: str, + description: str, + variable: tk.StringVar, + choices: tuple[str, ...], + ) -> None: + ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( + row=row, + column=0, + sticky="w", + ) + ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( + row=row, + column=1, + sticky="e", + ) + ttk.Combobox( + parent, + textvariable=variable, + values=choices, + state="readonly", + style="Settings.TCombobox", + ).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) + + def add_path( + parent: ttk.LabelFrame, + row: int, + label: str, + description: str, + variable: tk.StringVar, + browse: Callable[[], None], + ) -> None: + ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( + row=row, + column=0, + sticky="w", + ) + ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( + row=row, + column=1, + sticky="e", + ) + ttk.Entry(parent, textvariable=variable, style="Settings.TEntry").grid( + row=row + 1, + column=0, + columnspan=1, + sticky="ew", + pady=(3, 8), + ) + ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid( + row=row + 1, + column=1, + sticky="e", + padx=(8, 0), + pady=(3, 8), + ) + + def choose_proton_path() -> None: + selected = filedialog.askopenfilename( + parent=dialog, + title="Choose the Proton script", + ) + if selected: + proton_path_value.set(selected) + + def choose_game_source() -> None: + selected = filedialog.askdirectory( + parent=dialog, + title="Choose an existing PristonTale EU game directory", + mustexist=True, + ) + if selected: + game_source_value.set(selected) + + def close_settings() -> None: + nonlocal settings_window + if settings_window is not None and settings_window.winfo_exists(): + settings_window.destroy() + settings_window = None + + def integer_value(variable: tk.StringVar, name: str) -> int: + value = variable.get().strip() + try: + return int(value) + except ValueError as error: + raise ValueError(f"{name} must be a whole number") from error + + def save_current_settings() -> None: + try: + updated_settings = Settings( + width=integer_value(width_value, "Client width"), + height=integer_value(height_value, "Client height"), + output_width=integer_value(output_width_value, "Output width"), + output_height=integer_value(output_height_value, "Output height"), + refresh_rate=integer_value(refresh_rate_value, "Refresh rate"), + fullscreen=fullscreen_value.get(), + borderless=borderless_value.get(), + scaler=scaler_value.get(), + filter=filter_value.get(), + runtime=runtime_value.get(), + proton_path=proton_path_value.get().strip() or None, + game_source=game_source_value.get().strip() or None, + ) + save_settings(paths, updated_settings) + except (OSError, ValueError) as error: + messagebox.showerror("Settings", str(error), parent=dialog) + return + append_log(f"Saved launcher settings to {paths.settings_file}") + status.set("Settings saved. They will be used on the next game launch.") + progress_text.set("Open Settings again at any time to adjust the launcher.") + close_settings() + + display_card = ttk.LabelFrame( + content, + text="DISPLAY", + style="SettingsCard.TLabelframe", + padding=14, + ) + display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + display_card.columnconfigure(0, weight=1) + display_card.columnconfigure(1, weight=1) + ttk.Label( + display_card, + text="Choose the game canvas and the Gamescope output it is presented on.", + style="SettingsHint.TLabel", + wraplength=360, + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + add_dimensions( + display_card, + 1, + "Game resolution", + "Internal client size", + width_value, + height_value, + ) + add_dimensions( + display_card, + 3, + "Output resolution", + "Gamescope desktop", + output_width_value, + output_height_value, + ) + ttk.Label(display_card, text="Refresh rate", style="SettingsText.TLabel").grid( + row=5, + column=0, + sticky="w", + ) + ttk.Label( + display_card, + text="Output refresh", + style="SettingsHint.TLabel", + ).grid(row=5, column=1, sticky="e") + refresh_fields = ttk.Frame(display_card, style="SettingsCard.TFrame") + refresh_fields.grid(row=6, column=0, columnspan=2, sticky="w", pady=(3, 8)) + ttk.Entry( + refresh_fields, + textvariable=refresh_rate_value, + width=8, + style="Settings.TEntry", + ).pack(side="left") + ttk.Label(refresh_fields, text="Hz", style="SettingsHint.TLabel").pack( + side="left", + padx=(5, 0), + ) + presentation = ttk.Frame(display_card, style="SettingsCard.TFrame") + presentation.grid(row=7, column=0, columnspan=2, sticky="w") + ttk.Checkbutton( + presentation, + text="Fullscreen", + variable=fullscreen_value, + style="Settings.TCheckbutton", + ).pack(side="left") + ttk.Checkbutton( + presentation, + text="Borderless", + variable=borderless_value, + style="Settings.TCheckbutton", + ).pack(side="left", padx=(18, 0)) + + rendering_card = ttk.LabelFrame( + content, + text="RENDERING & RUNTIME", + style="SettingsCard.TLabelframe", + padding=14, + ) + rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) + rendering_card.columnconfigure(0, weight=1) + rendering_card.columnconfigure(1, weight=1) + ttk.Label( + rendering_card, + text="Control how the client is scaled and which compatible runtime launches it.", + style="SettingsHint.TLabel", + wraplength=360, + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + add_choice( + rendering_card, + 1, + "Scaling mode", + "Gamescope layout", + scaler_value, + ("auto", "integer", "fit", "fill", "stretch"), + ) + add_choice( + rendering_card, + 3, + "Image filter", + "Upscaling filter", + filter_value, + ("linear", "nearest", "fsr", "nis", "pixel"), + ) + ttk.Separator(rendering_card).grid( + row=5, + column=0, + columnspan=2, + sticky="ew", + pady=(2, 9), + ) + add_choice( + rendering_card, + 6, + "Runtime", + "Compatibility layer", + runtime_value, + ("auto", "proton7", "umu"), + ) + + overrides_card = ttk.LabelFrame( + content, + text="OPTIONAL OVERRIDES", + style="SettingsCard.TLabelframe", + padding=14, + ) + overrides_card.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + overrides_card.columnconfigure(0, weight=1) + ttk.Label( + overrides_card, + text="Leave these blank for automatic runtime and managed-game detection.", + style="SettingsHint.TLabel", + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) + add_path( + overrides_card, + 1, + "Proton script", + "Use a specific Proton executable", + proton_path_value, + choose_proton_path, + ) + add_path( + overrides_card, + 3, + "Game source", + "Import from this existing game folder", + game_source_value, + choose_game_source, + ) + + footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) + footer.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label( + footer, + text="Settings are stored locally and used the next time you press Play.", + style="SettingsFooter.TLabel", + ).grid(row=0, column=0, sticky="w") + buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame") + buttons_row.grid(row=0, column=1, sticky="e") + ttk.Button( + buttons_row, + text="CANCEL", + style="Secondary.TButton", + command=close_settings, + ).pack(side="left") + ttk.Button( + buttons_row, + text="SAVE SETTINGS", + style="Play.TButton", + command=save_current_settings, + ).pack(side="left", padx=(8, 0)) + + dialog.protocol("WM_DELETE_WINDOW", close_settings) + dialog.lift() + dialog.focus_set() + + settings_button.configure(command=open_settings) + + def choose_import_source() -> None: + source = filedialog.askdirectory( + title="Choose an existing PristonTale EU game directory", + mustexist=True, + ) + if not source: + return + run_operation( + "Importing the existing game into the managed installation…", + command_for("import-game", source), + ) + + import_button.configure(command=choose_import_source) + log_toggle.configure(command=toggle_log) + + def update_news_viewport(_event: object | None = None) -> None: + news_canvas.configure(scrollregion=news_canvas.bbox("all")) + + def resize_news_content(event: object) -> None: + width = getattr(event, "width", 0) + if isinstance(width, int) and width > 0: + news_canvas.itemconfigure(news_window, width=width) + + news_content.bind("", update_news_viewport) + news_canvas.bind("", resize_news_content) + refresh_primary_action() + poll_events() + load_official_logo() + refresh_remote_content() + try: + root.mainloop() + except KeyboardInterrupt: + try: + root.destroy() + except tk.TclError: + pass + + +def _as_int(value: object) -> int: + return value if isinstance(value, int) and value >= 0 else 0 + + +def _format_bytes(value: int) -> str: + units = ("B", "KiB", "MiB", "GiB") + amount = float(value) + for unit in units: + if amount < 1024 or unit == units[-1]: + return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}" + amount /= 1024 + return f"{value} B" + + +def format_transfer_rate(bytes_per_second: float) -> str: + return f"{_format_bytes(max(0, round(bytes_per_second)))}/s" diff --git a/ptlinux/v21_texture_reclaim.py b/ptlinux/v21_texture_reclaim.py new file mode 100644 index 0000000..39ea210 --- /dev/null +++ b/ptlinux/v21_texture_reclaim.py @@ -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( + " 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( + " int: + if len(image) < 0x40: + raise TextureReclaimPatchError("source is too small to be a PE image") + pe_offset = struct.unpack_from(" 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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7ef932 --- /dev/null +++ b/pyproject.toml @@ -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"